diff --git a/.github/workflows/codegen-check.yml b/.github/workflows/codegen-check.yml new file mode 100644 index 000000000..c7d295221 --- /dev/null +++ b/.github/workflows/codegen-check.yml @@ -0,0 +1,56 @@ +name: "Codegen Check" + +on: + push: + branches: + - main + pull_request: + paths: + - 'scripts/codegen/**' + - 'nodejs/src/generated/**' + - 'dotnet/src/Generated/**' + - 'python/copilot/generated/**' + - 'go/generated_*.go' + - 'go/rpc/**' + - '.github/workflows/codegen-check.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + name: "Verify generated files are up-to-date" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Install nodejs SDK dependencies + working-directory: ./nodejs + run: npm ci + + - name: Install codegen dependencies + working-directory: ./scripts/codegen + run: npm ci + + - name: Run codegen + working-directory: ./scripts/codegen + run: npm run generate + + - name: Check for uncommitted changes + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "::error::Generated files are out of date. Run 'cd scripts/codegen && npm run generate' and commit the changes." + git diff --stat + git diff + exit 1 + fi + echo "✅ Generated files are up-to-date" diff --git a/.github/workflows/dotnet-sdk-tests.yml b/.github/workflows/dotnet-sdk-tests.yml index c86b37920..bbe577bc1 100644 --- a/.github/workflows/dotnet-sdk-tests.yml +++ b/.github/workflows/dotnet-sdk-tests.yml @@ -46,7 +46,7 @@ jobs: dotnet-version: "8.0.x" - uses: actions/setup-node@v6 with: - node-version: "24" + node-version: "22" cache: "npm" cache-dependency-path: "./nodejs/package-lock.json" diff --git a/.github/workflows/nodejs-sdk-tests.yml b/.github/workflows/nodejs-sdk-tests.yml index 088d94a5b..5947396d0 100644 --- a/.github/workflows/nodejs-sdk-tests.yml +++ b/.github/workflows/nodejs-sdk-tests.yml @@ -47,7 +47,7 @@ jobs: with: cache: "npm" cache-dependency-path: "./nodejs/package-lock.json" - node-version: 24 + node-version: 22 - name: Install dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/python-sdk-tests.yml b/.github/workflows/python-sdk-tests.yml index 560288d2d..079395b32 100644 --- a/.github/workflows/python-sdk-tests.yml +++ b/.github/workflows/python-sdk-tests.yml @@ -49,7 +49,7 @@ jobs: python-version: "3.12" - uses: actions/setup-node@v6 with: - node-version: "24" + node-version: "22" cache: "npm" cache-dependency-path: "./nodejs/package-lock.json" @@ -59,7 +59,7 @@ jobs: enable-cache: true - name: Install Python dev dependencies - run: uv sync --locked --all-extras --dev + run: uv sync --all-extras --dev - name: Install Node.js dependencies (for CLI in tests) working-directory: ./nodejs diff --git a/.github/workflows/scenario-builds.yml b/.github/workflows/scenario-builds.yml new file mode 100644 index 000000000..a66ede5ec --- /dev/null +++ b/.github/workflows/scenario-builds.yml @@ -0,0 +1,186 @@ +name: "Scenario Build Verification" + +on: + pull_request: + paths: + - "test/scenarios/**" + - "nodejs/src/**" + - "python/copilot/**" + - "go/**/*.go" + - "dotnet/src/**" + - ".github/workflows/scenario-builds.yml" + push: + branches: + - main + paths: + - "test/scenarios/**" + - ".github/workflows/scenario-builds.yml" + workflow_dispatch: + merge_group: + +permissions: + contents: read + +jobs: + # ── TypeScript ────────────────────────────────────────────────────── + build-typescript: + name: "TypeScript scenarios" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + + - uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-npm-scenarios-${{ hashFiles('test/scenarios/**/package.json') }} + restore-keys: | + ${{ runner.os }}-npm-scenarios- + + # Build the SDK so local file: references resolve + - name: Build SDK + working-directory: nodejs + run: npm ci --ignore-scripts + + - name: Build all TypeScript scenarios + run: | + PASS=0; FAIL=0; FAILURES="" + for dir in $(find test/scenarios -path '*/typescript/package.json' -exec dirname {} \; | sort); do + scenario="${dir#test/scenarios/}" + echo "::group::$scenario" + if (cd "$dir" && npm install --ignore-scripts 2>&1); then + echo "✅ $scenario" + PASS=$((PASS + 1)) + else + echo "❌ $scenario" + FAIL=$((FAIL + 1)) + FAILURES="$FAILURES\n $scenario" + fi + echo "::endgroup::" + done + echo "" + echo "TypeScript builds: $PASS passed, $FAIL failed" + if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$FAILURES" + exit 1 + fi + + # ── Python ────────────────────────────────────────────────────────── + build-python: + name: "Python scenarios" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install Python SDK + run: pip install -e python/ + + - name: Compile and import-check all Python scenarios + run: | + PASS=0; FAIL=0; FAILURES="" + for main in $(find test/scenarios -path '*/python/main.py' | sort); do + dir=$(dirname "$main") + scenario="${dir#test/scenarios/}" + echo "::group::$scenario" + if python3 -m py_compile "$main" 2>&1 && python3 -c "import copilot" 2>&1; then + echo "✅ $scenario" + PASS=$((PASS + 1)) + else + echo "❌ $scenario" + FAIL=$((FAIL + 1)) + FAILURES="$FAILURES\n $scenario" + fi + echo "::endgroup::" + done + echo "" + echo "Python builds: $PASS passed, $FAIL failed" + if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$FAILURES" + exit 1 + fi + + # ── Go ────────────────────────────────────────────────────────────── + build-go: + name: "Go scenarios" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: "1.24" + cache: true + cache-dependency-path: test/scenarios/**/go.sum + + - name: Build all Go scenarios + run: | + PASS=0; FAIL=0; FAILURES="" + for mod in $(find test/scenarios -path '*/go/go.mod' | sort); do + dir=$(dirname "$mod") + scenario="${dir#test/scenarios/}" + echo "::group::$scenario" + if (cd "$dir" && go build ./... 2>&1); then + echo "✅ $scenario" + PASS=$((PASS + 1)) + else + echo "❌ $scenario" + FAIL=$((FAIL + 1)) + FAILURES="$FAILURES\n $scenario" + fi + echo "::endgroup::" + done + echo "" + echo "Go builds: $PASS passed, $FAIL failed" + if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$FAILURES" + exit 1 + fi + + # ── C# ───────────────────────────────────────────────────────────── + build-csharp: + name: "C# scenarios" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: "8.0.x" + + - uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-scenarios-${{ hashFiles('test/scenarios/**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget-scenarios- + + - name: Build all C# scenarios + run: | + PASS=0; FAIL=0; FAILURES="" + for proj in $(find test/scenarios -name '*.csproj' | sort); do + dir=$(dirname "$proj") + scenario="${dir#test/scenarios/}" + echo "::group::$scenario" + if (cd "$dir" && dotnet build --nologo 2>&1); then + echo "✅ $scenario" + PASS=$((PASS + 1)) + else + echo "❌ $scenario" + FAIL=$((FAIL + 1)) + FAILURES="$FAILURES\n $scenario" + fi + echo "::endgroup::" + done + echo "" + echo "C# builds: $PASS passed, $FAIL failed" + if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$FAILURES" + exit 1 + fi diff --git a/.gitignore b/.gitignore index 9ec30582d..6ff86481d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ # Documentation validation output docs/.validation/ +.DS_Store diff --git a/README.md b/README.md index bf74289bc..be9b4694b 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ The GitHub Copilot SDK exposes the same engine behind Copilot CLI: a production- ## Available SDKs -| SDK | Location | Installation | -| ------------------------ | ------------------------------------------------- | ----------------------------------------- | -| **Node.js / TypeScript** | [`cookbook/nodejs/`](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/nodejs/README.md) | `npm install @github/copilot-sdk` | -| **Python** | [`cookbook/python/`](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/python/README.md) | `pip install github-copilot-sdk` | -| **Go** | [`cookbook/go/`](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/go/README.md) | `go get github.com/github/copilot-sdk/go` | -| **.NET** | [`cookbook/dotnet/`](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/dotnet/README.md) | `dotnet add package GitHub.Copilot.SDK` | +| SDK | Location | Cookbook | Installation | +| ------------------------ | -------------- | ------------------------------------------------- | ----------------------------------------- | +| **Node.js / TypeScript** | [`nodejs/`](./nodejs/) | [Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/nodejs/README.md) | `npm install @github/copilot-sdk` | +| **Python** | [`python/`](./python/) | [Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/python/README.md) | `pip install github-copilot-sdk` | +| **Go** | [`go/`](./go/) | [Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/go/README.md) | `go get github.com/github/copilot-sdk/go` | +| **.NET** | [`dotnet/`](./dotnet/) | [Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/dotnet/README.md) | `dotnet add package GitHub.Copilot.SDK` | See the individual SDK READMEs for installation, usage examples, and API reference. @@ -116,17 +116,17 @@ Please use the [GitHub Issues](https://github.com/github/copilot-sdk/issues) pag ⚠️ Disclaimer: These are unofficial, community-driven SDKs and they are not supported by GitHub. Use at your own risk. -| SDK | Location | -| --------------| -------------------------------------------------- | -| **Java** | [copilot-community-sdk/copilot-sdk-java][sdk-java] | -| **Rust** | [copilot-community-sdk/copilot-sdk-rust][sdk-rust] | -| **C++** | [0xeb/copilot-sdk-cpp][sdk-cpp] | -| **Clojure** | [krukow/copilot-sdk-clojure][sdk-clojure] | +| SDK | Location | +| --------------| ----------------------------------------------------------------- | +| **Java** | [copilot-community-sdk/copilot-sdk-java][sdk-java] | +| **Rust** | [copilot-community-sdk/copilot-sdk-rust][sdk-rust] | +| **Clojure** | [copilot-community-sdk/copilot-sdk-clojure][sdk-clojure] | +| **C++** | [0xeb/copilot-sdk-cpp][sdk-cpp] | [sdk-java]: https://github.com/copilot-community-sdk/copilot-sdk-java [sdk-rust]: https://github.com/copilot-community-sdk/copilot-sdk-rust [sdk-cpp]: https://github.com/0xeb/copilot-sdk-cpp -[sdk-clojure]: https://github.com/krukow/copilot-sdk-clojure +[sdk-clojure]: https://github.com/copilot-community-sdk/copilot-sdk-clojure ## Contributing diff --git a/docs/compatibility.md b/docs/compatibility.md index bc8f54cd3..268c077a3 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -124,20 +124,13 @@ The `--share` option is not available via SDK. Workarounds: ### Permission Control +The SDK uses a **deny-by-default** permission model. All permission requests (file writes, shell commands, URL fetches, etc.) are denied unless your app provides an `onPermissionRequest` handler. + Instead of `--allow-all-paths` or `--yolo`, use the permission handler: ```typescript const session = await client.createSession({ - onPermissionRequest: async (request) => { - // Auto-approve everything (equivalent to --yolo) - return { approved: true }; - - // Or implement custom logic - if (request.kind === "shell") { - return { approved: request.command.startsWith("git") }; - } - return { approved: true }; - }, + onPermissionRequest: approveAll, }); ``` diff --git a/docs/guides/session-persistence.md b/docs/guides/session-persistence.md index d1fb39e62..527f5ecc7 100644 --- a/docs/guides/session-persistence.md +++ b/docs/guides/session-persistence.md @@ -293,12 +293,16 @@ session_id = create_session_id("alice", "code-review") ### Listing Active Sessions ```typescript +// List all sessions const sessions = await client.listSessions(); console.log(`Found ${sessions.length} sessions`); for (const session of sessions) { console.log(`- ${session.sessionId} (created: ${session.createdAt})`); } + +// Filter sessions by repository +const repoSessions = await client.listSessions({ repository: "owner/repo" }); ``` ### Cleaning Up Old Sessions @@ -521,7 +525,7 @@ await withSessionLock("user-123-task-456", async () => { | **Create resumable session** | Provide your own `sessionId` | | **Resume session** | `client.resumeSession(sessionId)` | | **BYOK resume** | Re-provide `provider` config | -| **List sessions** | `client.listSessions()` | +| **List sessions** | `client.listSessions(filter?)` | | **Delete session** | `client.deleteSession(sessionId)` | | **Destroy active session** | `session.destroy()` | | **Containerized deployment** | Mount `~/.copilot/session-state/` to persistent storage | diff --git a/docs/guides/setup/azure-managed-identity.md b/docs/guides/setup/azure-managed-identity.md new file mode 100644 index 000000000..bfafc6f91 --- /dev/null +++ b/docs/guides/setup/azure-managed-identity.md @@ -0,0 +1,217 @@ +# Azure Managed Identity with BYOK + +The Copilot SDK's [BYOK mode](./byok.md) accepts static API keys, but Azure deployments often use **Managed Identity** (Entra ID) instead of long-lived keys. Since the SDK doesn't natively support Entra ID authentication, you can use a short-lived bearer token via the `bearer_token` provider config field. + +This guide shows how to use `DefaultAzureCredential` from the [Azure Identity](https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential) library to authenticate with Azure AI Foundry models through the Copilot SDK. + +## How It Works + +Azure AI Foundry's OpenAI-compatible endpoint accepts bearer tokens from Entra ID in place of static API keys. The pattern is: + +1. Use `DefaultAzureCredential` to obtain a token for the `https://cognitiveservices.azure.com/.default` scope +2. Pass the token as the `bearer_token` in the BYOK provider config +3. Refresh the token before it expires (tokens are typically valid for ~1 hour) + +```mermaid +sequenceDiagram + participant App as Your Application + participant AAD as Entra ID + participant SDK as Copilot SDK + participant Foundry as Azure AI Foundry + + App->>AAD: DefaultAzureCredential.get_token() + AAD-->>App: Bearer token (~1hr) + App->>SDK: create_session(provider={bearer_token: token}) + SDK->>Foundry: Request with Authorization: Bearer + Foundry-->>SDK: Model response + SDK-->>App: Session events +``` + +## Python Example + +### Prerequisites + +```bash +pip install github-copilot-sdk azure-identity +``` + +### Basic Usage + +```python +import asyncio +import os + +from azure.identity import DefaultAzureCredential +from copilot import CopilotClient, ProviderConfig, SessionConfig + +COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default" + + +async def main(): + # Get a token using Managed Identity, Azure CLI, or other credential chain + credential = DefaultAzureCredential() + token = credential.get_token(COGNITIVE_SERVICES_SCOPE).token + + foundry_url = os.environ["AZURE_AI_FOUNDRY_RESOURCE_URL"] + + client = CopilotClient() + await client.start() + + session = await client.create_session( + SessionConfig( + model="gpt-4.1", + provider=ProviderConfig( + type="openai", + base_url=f"{foundry_url.rstrip('/')}/openai/v1/", + bearer_token=token, # Short-lived bearer token + wire_api="responses", + ), + ) + ) + + response = await session.send_and_wait({"prompt": "Hello from Managed Identity!"}) + print(response.data.content) + + await client.stop() + + +asyncio.run(main()) +``` + +### Token Refresh for Long-Running Applications + +Bearer tokens expire (typically after ~1 hour). For servers or long-running agents, refresh the token before creating each session: + +```python +from azure.identity import DefaultAzureCredential +from copilot import CopilotClient, ProviderConfig, SessionConfig + +COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default" + + +class ManagedIdentityCopilotAgent: + """Copilot agent that refreshes Entra ID tokens for Azure AI Foundry.""" + + def __init__(self, foundry_url: str, model: str = "gpt-4.1"): + self.foundry_url = foundry_url.rstrip("/") + self.model = model + self.credential = DefaultAzureCredential() + self.client = CopilotClient() + + def _get_session_config(self) -> SessionConfig: + """Build a SessionConfig with a fresh bearer token.""" + token = self.credential.get_token(COGNITIVE_SERVICES_SCOPE).token + return SessionConfig( + model=self.model, + provider=ProviderConfig( + type="openai", + base_url=f"{self.foundry_url}/openai/v1/", + bearer_token=token, + wire_api="responses", + ), + ) + + async def chat(self, prompt: str) -> str: + """Send a prompt and return the response text.""" + # Fresh token for each session + config = self._get_session_config() + session = await self.client.create_session(config) + + response = await session.send_and_wait({"prompt": prompt}) + await session.destroy() + + return response.data.content if response else "" +``` + +## Node.js / TypeScript Example + + +```typescript +import { DefaultAzureCredential } from "@azure/identity"; +import { CopilotClient } from "@github/copilot-sdk"; + +const credential = new DefaultAzureCredential(); +const tokenResponse = await credential.getToken( + "https://cognitiveservices.azure.com/.default" +); + +const client = new CopilotClient(); + +const session = await client.createSession({ + model: "gpt-4.1", + provider: { + type: "openai", + baseUrl: `${process.env.AZURE_AI_FOUNDRY_RESOURCE_URL}/openai/v1/`, + bearerToken: tokenResponse.token, + wireApi: "responses", + }, +}); + +const response = await session.sendAndWait({ prompt: "Hello!" }); +console.log(response?.data.content); + +await client.stop(); +``` + +## .NET Example + + +```csharp +using Azure.Identity; +using GitHub.Copilot; + +var credential = new DefaultAzureCredential(); +var token = await credential.GetTokenAsync( + new Azure.Core.TokenRequestContext( + new[] { "https://cognitiveservices.azure.com/.default" })); + +await using var client = new CopilotClient(); +var foundryUrl = Environment.GetEnvironmentVariable("AZURE_AI_FOUNDRY_RESOURCE_URL"); + +await using var session = await client.CreateSessionAsync(new SessionConfig +{ + Model = "gpt-4.1", + Provider = new ProviderConfig + { + Type = "openai", + BaseUrl = $"{foundryUrl!.TrimEnd('/')}/openai/v1/", + BearerToken = token.Token, + WireApi = "responses", + }, +}); + +var response = await session.SendAndWaitAsync( + new MessageOptions { Prompt = "Hello from Managed Identity!" }); +Console.WriteLine(response?.Data.Content); +``` + +## Environment Configuration + +| Variable | Description | Example | +|----------|-------------|---------| +| `AZURE_AI_FOUNDRY_RESOURCE_URL` | Your Azure AI Foundry resource URL | `https://myresource.openai.azure.com` | + +No API key environment variable is needed — authentication is handled by `DefaultAzureCredential`, which automatically supports: + +- **Managed Identity** (system-assigned or user-assigned) — for Azure-hosted apps +- **Azure CLI** (`az login`) — for local development +- **Environment variables** (`AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_SECRET`) — for service principals +- **Workload Identity** — for Kubernetes + +See the [DefaultAzureCredential documentation](https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential) for the full credential chain. + +## When to Use This Pattern + +| Scenario | Recommendation | +|----------|----------------| +| Azure-hosted app with Managed Identity | ✅ Use this pattern | +| App with existing Azure AD service principal | ✅ Use this pattern | +| Local development with `az login` | ✅ Use this pattern | +| Non-Azure environment with static API key | Use [standard BYOK](./byok.md) | +| GitHub Copilot subscription available | Use [GitHub OAuth](./github-oauth.md) | + +## See Also + +- [BYOK Setup Guide](./byok.md) — Static API key configuration +- [Backend Services](./backend-services.md) — Server-side deployment +- [Azure Identity documentation](https://learn.microsoft.com/python/api/overview/azure/identity-readme) diff --git a/docs/guides/setup/backend-services.md b/docs/guides/setup/backend-services.md new file mode 100644 index 000000000..c9bc13f8d --- /dev/null +++ b/docs/guides/setup/backend-services.md @@ -0,0 +1,432 @@ +# Backend Services Setup + +Run the Copilot SDK in server-side applications — APIs, web backends, microservices, and background workers. The CLI runs as a headless server that your backend code connects to over the network. + +**Best for:** Web app backends, API services, internal tools, CI/CD integrations, any server-side workload. + +## 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. + +```mermaid +flowchart TB + subgraph Backend["Your Backend"] + API["API Server"] + SDK["SDK Client"] + end + + subgraph CLIServer["Copilot CLI (Headless)"] + RPC["JSON-RPC Server
TCP :4321"] + Sessions["Session Manager"] + end + + Users["👥 Users"] --> API + API --> SDK + SDK -- "cliUrl: localhost:4321" --> RPC + RPC --> Sessions + RPC --> Copilot["☁️ GitHub Copilot
or Model Provider"] + + style Backend fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 + style CLIServer fill:#0d1117,stroke:#3fb950,color:#c9d1d9 +``` + +**Key characteristics:** +- CLI runs as a persistent server process (not spawned per request) +- SDK connects over TCP — CLI and app can run in different containers +- Multiple SDK clients can share one CLI server +- Works with any auth method (GitHub tokens, env vars, BYOK) + +## Architecture: Auto-Managed vs. External CLI + +```mermaid +flowchart LR + subgraph Auto["Auto-Managed (Default)"] + A1["SDK"] -->|"spawns"| A2["CLI Process"] + A2 -.->|"dies with app"| A1 + end + + subgraph External["External Server (Backend)"] + B1["SDK"] -->|"cliUrl"| B2["CLI Server"] + B2 -.->|"independent
lifecycle"| B1 + end + + style Auto fill:#161b22,stroke:#8b949e,color:#c9d1d9 + style External fill:#0d1117,stroke:#3fb950,color:#c9d1d9 +``` + +## Step 1: Start the CLI in Headless Mode + +Run the CLI as a background server: + +```bash +# Start with a specific port +copilot --headless --port 4321 + +# Or let it pick a random port (prints the URL) +copilot --headless +# Output: Listening on http://localhost:52431 +``` + +For production, run it as a system service or in a container: + +```bash +# Docker +docker run -d --name copilot-cli \ + -p 4321:4321 \ + -e COPILOT_GITHUB_TOKEN="$TOKEN" \ + ghcr.io/github/copilot-cli:latest \ + --headless --port 4321 + +# systemd +[Service] +ExecStart=/usr/local/bin/copilot --headless --port 4321 +Environment=COPILOT_GITHUB_TOKEN=your-token +Restart=always +``` + +## Step 2: Connect the SDK + +
+Node.js / TypeScript + +```typescript +import { CopilotClient } from "@github/copilot-sdk"; + +const client = new CopilotClient({ + cliUrl: "localhost:4321", +}); + +const session = await client.createSession({ + sessionId: `user-${userId}-${Date.now()}`, + model: "gpt-4.1", +}); + +const response = await session.sendAndWait({ prompt: req.body.message }); +res.json({ content: response?.data.content }); +``` + +
+ +
+Python + +```python +from copilot import CopilotClient + +client = CopilotClient({ + "cli_url": "localhost:4321", +}) +await client.start() + +session = await client.create_session({ + "session_id": f"user-{user_id}-{int(time.time())}", + "model": "gpt-4.1", +}) + +response = await session.send_and_wait({"prompt": message}) +``` + +
+ +
+Go + + +```go +client := copilot.NewClient(&copilot.ClientOptions{ + CLIUrl:"localhost:4321", +}) +client.Start(ctx) +defer client.Stop() + +session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ + SessionID: fmt.Sprintf("user-%s-%d", userID, time.Now().Unix()), + Model: "gpt-4.1", +}) + +response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: message}) +``` + +
+ +
+.NET + + +```csharp +var client = new CopilotClient(new CopilotClientOptions +{ + CliUrl = "localhost:4321", + UseStdio = false, +}); + +await using var session = await client.CreateSessionAsync(new SessionConfig +{ + SessionId = $"user-{userId}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}", + Model = "gpt-4.1", +}); + +var response = await session.SendAndWaitAsync( + new MessageOptions { Prompt = message }); +``` + +
+ +## Authentication for Backend Services + +### Environment Variable Tokens + +The simplest approach — set a token on the CLI server: + +```mermaid +flowchart LR + subgraph Server + EnvVar["COPILOT_GITHUB_TOKEN"] + CLI["Copilot CLI"] + end + + EnvVar --> CLI + CLI --> Copilot["☁️ Copilot API"] + + style Server fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 +``` + +```bash +# All requests use this token +export COPILOT_GITHUB_TOKEN="gho_service_account_token" +copilot --headless --port 4321 +``` + +### Per-User Tokens (OAuth) + +Pass individual user tokens when creating sessions. See [GitHub OAuth](./github-oauth.md) for the full flow. + +```typescript +// Your API receives user tokens from your auth layer +app.post("/chat", authMiddleware, async (req, res) => { + const client = new CopilotClient({ + cliUrl: "localhost:4321", + githubToken: req.user.githubToken, + useLoggedInUser: false, + }); + + const session = await client.createSession({ + sessionId: `user-${req.user.id}-chat`, + model: "gpt-4.1", + }); + + const response = await session.sendAndWait({ + prompt: req.body.message, + }); + + res.json({ content: response?.data.content }); +}); +``` + +### BYOK (No GitHub Auth) + +Use your own API keys for the model provider. See [BYOK](./byok.md) for details. + +```typescript +const client = new CopilotClient({ + cliUrl: "localhost:4321", +}); + +const session = await client.createSession({ + model: "gpt-4.1", + provider: { + type: "openai", + baseUrl: "https://api.openai.com/v1", + apiKey: process.env.OPENAI_API_KEY, + }, +}); +``` + +## Common Backend Patterns + +### Web API with Express + +```mermaid +flowchart TB + Users["👥 Users"] --> LB["Load Balancer"] + LB --> API1["API Instance 1"] + LB --> API2["API Instance 2"] + + API1 --> CLI["Copilot CLI
(headless :4321)"] + API2 --> CLI + + CLI --> Cloud["☁️ Model Provider"] + + style API1 fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 + style API2 fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 + style CLI fill:#0d1117,stroke:#3fb950,color:#c9d1d9 +``` + +```typescript +import express from "express"; +import { CopilotClient } from "@github/copilot-sdk"; + +const app = express(); +app.use(express.json()); + +// Single shared CLI connection +const client = new CopilotClient({ + cliUrl: process.env.CLI_URL || "localhost:4321", +}); + +app.post("/api/chat", async (req, res) => { + const { sessionId, message } = req.body; + + // Create or resume session + let session; + try { + session = await client.resumeSession(sessionId); + } catch { + session = await client.createSession({ + sessionId, + model: "gpt-4.1", + }); + } + + const response = await session.sendAndWait({ prompt: message }); + res.json({ + sessionId, + content: response?.data.content, + }); +}); + +app.listen(3000); +``` + +### Background Worker + +```typescript +import { CopilotClient } from "@github/copilot-sdk"; + +const client = new CopilotClient({ + cliUrl: process.env.CLI_URL || "localhost:4321", +}); + +// Process jobs from a queue +async function processJob(job: Job) { + const session = await client.createSession({ + sessionId: `job-${job.id}`, + model: "gpt-4.1", + }); + + const response = await session.sendAndWait({ + prompt: job.prompt, + }); + + await saveResult(job.id, response?.data.content); + await session.destroy(); // Clean up after job completes +} +``` + +### Docker Compose Deployment + +```yaml +version: "3.8" + +services: + copilot-cli: + image: ghcr.io/github/copilot-cli:latest + command: ["--headless", "--port", "4321"] + environment: + - COPILOT_GITHUB_TOKEN=${COPILOT_GITHUB_TOKEN} + ports: + - "4321:4321" + restart: always + volumes: + - session-data:/root/.copilot/session-state + + api: + build: . + environment: + - CLI_URL=copilot-cli:4321 + depends_on: + - copilot-cli + ports: + - "3000:3000" + +volumes: + session-data: +``` + +```mermaid +flowchart TB + subgraph Docker["Docker Compose"] + API["api:3000"] + CLI["copilot-cli:4321"] + Vol["📁 session-data
(persistent volume)"] + end + + Users["👥 Users"] --> API + API --> CLI + CLI --> Vol + + CLI --> Cloud["☁️ Copilot / Provider"] + + style Docker fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 +``` + +## Health Checks + +Monitor the CLI server's health: + +```typescript +// Periodic health check +async function checkCLIHealth(): Promise { + try { + const status = await client.getStatus(); + return status !== undefined; + } catch { + return false; + } +} +``` + +## Session Cleanup + +Backend services should actively clean up sessions to avoid resource leaks: + +```typescript +// Clean up expired sessions periodically +async function cleanupSessions(maxAgeMs: number) { + const sessions = await client.listSessions(); + const now = Date.now(); + + for (const session of sessions) { + const age = now - new Date(session.createdAt).getTime(); + if (age > maxAgeMs) { + await client.deleteSession(session.sessionId); + } + } +} + +// Run every hour +setInterval(() => cleanupSessions(24 * 60 * 60 * 1000), 60 * 60 * 1000); +``` + +## Limitations + +| Limitation | Details | +|------------|---------| +| **Single CLI server = single point of failure** | See [Scaling guide](./scaling.md) for HA patterns | +| **No built-in auth between SDK and CLI** | Secure the network path (same host, VPC, etc.) | +| **Session state on local disk** | Mount persistent storage for container restarts | +| **30-minute idle timeout** | Sessions without activity are auto-cleaned | + +## When to Move On + +| Need | Next Guide | +|------|-----------| +| Multiple CLI servers / high availability | [Scaling & Multi-Tenancy](./scaling.md) | +| GitHub account auth for users | [GitHub OAuth](./github-oauth.md) | +| Your own model keys | [BYOK](./byok.md) | + +## Next Steps + +- **[Scaling & Multi-Tenancy](./scaling.md)** — Handle more users, add redundancy +- **[Session Persistence](../session-persistence.md)** — Resume sessions across restarts +- **[GitHub OAuth](./github-oauth.md)** — Add user authentication diff --git a/docs/guides/setup/bundled-cli.md b/docs/guides/setup/bundled-cli.md new file mode 100644 index 000000000..6daf57b56 --- /dev/null +++ b/docs/guides/setup/bundled-cli.md @@ -0,0 +1,327 @@ +# Bundled CLI Setup + +Package the Copilot CLI alongside your application so users don't need to install or configure anything separately. Your app ships with everything it needs. + +**Best for:** Desktop apps, standalone tools, Electron apps, distributable CLI utilities. + +## How It Works + +Instead of relying on a globally installed CLI, you include the CLI binary in your application bundle. The SDK points to your bundled copy via the `cliPath` option. + +```mermaid +flowchart TB + subgraph Bundle["Your Distributed App"] + App["Application Code"] + SDK["SDK Client"] + CLIBin["Copilot CLI Binary
(bundled)"] + end + + App --> SDK + SDK -- "cliPath" --> CLIBin + CLIBin -- "API calls" --> Copilot["☁️ GitHub Copilot"] + + style Bundle fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 +``` + +**Key characteristics:** +- CLI binary ships with your app — no separate install needed +- You control the exact CLI version your app uses +- Users authenticate through your app (or use env vars / BYOK) +- Sessions are managed per-user on their machine + +## Architecture: Bundled vs. Installed + +```mermaid +flowchart LR + subgraph Installed["Standard Setup"] + A1["Your App"] --> SDK1["SDK"] + SDK1 --> CLI1["Global CLI
(/usr/local/bin/copilot)"] + end + + subgraph Bundled["Bundled Setup"] + A2["Your App"] --> SDK2["SDK"] + SDK2 --> CLI2["Bundled CLI
(./vendor/copilot)"] + end + + style Installed fill:#161b22,stroke:#8b949e,color:#c9d1d9 + style Bundled fill:#0d1117,stroke:#3fb950,color:#c9d1d9 +``` + +## Setup + +### 1. Include the CLI in Your Project + +The CLI is distributed as part of the `@github/copilot` npm package. You can also obtain platform-specific binaries for your distribution pipeline. + +```bash +# The CLI is available from the @github/copilot package +npm install @github/copilot +``` + +### 2. Point the SDK to Your Bundled CLI + +
+Node.js / TypeScript + +```typescript +import { CopilotClient } from "@github/copilot-sdk"; +import path from "path"; + +const client = new CopilotClient({ + // Point to the CLI binary in your app bundle + cliPath: path.join(__dirname, "vendor", "copilot"), +}); + +const session = await client.createSession({ model: "gpt-4.1" }); +const response = await session.sendAndWait({ prompt: "Hello!" }); +console.log(response?.data.content); + +await client.stop(); +``` + +
+ +
+Python + +```python +from copilot import CopilotClient +from pathlib import Path + +client = CopilotClient({ + "cli_path": str(Path(__file__).parent / "vendor" / "copilot"), +}) +await client.start() + +session = await client.create_session({"model": "gpt-4.1"}) +response = await session.send_and_wait({"prompt": "Hello!"}) +print(response.data.content) + +await client.stop() +``` + +
+ +
+Go + + +```go +client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath:"./vendor/copilot", +}) +if err := client.Start(ctx); err != nil { + log.Fatal(err) +} +defer client.Stop() + +session, _ := client.CreateSession(ctx, &copilot.SessionConfig{Model: "gpt-4.1"}) +response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: "Hello!"}) +fmt.Println(*response.Data.Content) +``` + +
+ +
+.NET + +```csharp +var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Path.Combine(AppContext.BaseDirectory, "vendor", "copilot"), +}); + +await using var session = await client.CreateSessionAsync( + new SessionConfig { Model = "gpt-4.1" }); + +var response = await session.SendAndWaitAsync( + new MessageOptions { Prompt = "Hello!" }); +Console.WriteLine(response?.Data.Content); +``` + +
+ +## Authentication Strategies + +When bundling, you need to decide how your users will authenticate. Here are the common patterns: + +```mermaid +flowchart TB + App["Bundled App"] + + App --> A["User signs in to CLI
(keychain credentials)"] + App --> B["App provides token
(OAuth / env var)"] + App --> C["BYOK
(your own API keys)"] + + A --> Note1["User runs 'copilot' once
to authenticate"] + B --> Note2["Your app handles login
and passes token"] + C --> Note3["No GitHub auth needed
Uses your model provider"] + + style App fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 +``` + +### Option A: User's Signed-In Credentials (Simplest) + +The user signs in to the CLI once, and your bundled app uses those credentials. No extra code needed — this is the default behavior. + +```typescript +const client = new CopilotClient({ + cliPath: path.join(__dirname, "vendor", "copilot"), + // Default: uses signed-in user credentials +}); +``` + +### Option B: Token via Environment Variable + +Ship your app with instructions to set a token, or set it programmatically: + +```typescript +const client = new CopilotClient({ + cliPath: path.join(__dirname, "vendor", "copilot"), + env: { + COPILOT_GITHUB_TOKEN: getUserToken(), // Your app provides the token + }, +}); +``` + +### Option C: BYOK (No GitHub Auth Needed) + +If you manage your own model provider keys, users don't need GitHub accounts at all: + +```typescript +const client = new CopilotClient({ + cliPath: path.join(__dirname, "vendor", "copilot"), +}); + +const session = await client.createSession({ + model: "gpt-4.1", + provider: { + type: "openai", + baseUrl: "https://api.openai.com/v1", + apiKey: process.env.OPENAI_API_KEY, + }, +}); +``` + +See the **[BYOK guide](./byok.md)** for full details. + +## Session Management + +Bundled apps typically want named sessions so users can resume conversations: + +```typescript +const client = new CopilotClient({ + cliPath: path.join(__dirname, "vendor", "copilot"), +}); + +// Create a session tied to the user's project +const sessionId = `project-${projectName}`; +const session = await client.createSession({ + sessionId, + model: "gpt-4.1", +}); + +// User closes app... +// Later, resume where they left off +const resumed = await client.resumeSession(sessionId); +``` + +Session state persists at `~/.copilot/session-state/{sessionId}/`. + +## Distribution Patterns + +### Desktop App (Electron, Tauri) + +```mermaid +flowchart TB + subgraph Electron["Desktop App Package"] + UI["App UI"] --> Main["Main Process"] + Main --> SDK["SDK Client"] + SDK --> CLI["Copilot CLI
(in app resources)"] + end + CLI --> Cloud["☁️ GitHub Copilot"] + + style Electron fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 +``` + +Include the CLI binary in your app's resources directory: + +```typescript +import { app } from "electron"; +import path from "path"; + +const cliPath = path.join( + app.isPackaged ? process.resourcesPath : __dirname, + "copilot" +); + +const client = new CopilotClient({ cliPath }); +``` + +### CLI Tool + +For distributable CLI tools, resolve the path relative to your binary: + +```typescript +import { fileURLToPath } from "url"; +import path from "path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const cliPath = path.join(__dirname, "..", "vendor", "copilot"); + +const client = new CopilotClient({ cliPath }); +``` + +## Platform-Specific Binaries + +When distributing for multiple platforms, include the correct binary for each: + +``` +my-app/ +├── vendor/ +│ ├── copilot-darwin-arm64 # macOS Apple Silicon +│ ├── copilot-darwin-x64 # macOS Intel +│ ├── copilot-linux-x64 # Linux x64 +│ └── copilot-win-x64.exe # Windows x64 +└── src/ + └── index.ts +``` + +```typescript +import os from "os"; + +function getCLIPath(): string { + const platform = process.platform; // "darwin", "linux", "win32" + const arch = os.arch(); // "arm64", "x64" + const ext = platform === "win32" ? ".exe" : ""; + const name = `copilot-${platform}-${arch}${ext}`; + return path.join(__dirname, "vendor", name); +} + +const client = new CopilotClient({ + cliPath: getCLIPath(), +}); +``` + +## Limitations + +| Limitation | Details | +|------------|---------| +| **Bundle size** | CLI binary adds to your app's distribution size | +| **Updates** | You manage CLI version updates in your release cycle | +| **Platform builds** | Need separate binaries for each OS/architecture | +| **Single user** | Each bundled CLI instance serves one user | + +## When to Move On + +| Need | Next Guide | +|------|-----------| +| Users signing in with GitHub accounts | [GitHub OAuth](./github-oauth.md) | +| Run on a server instead of user machines | [Backend Services](./backend-services.md) | +| Use your own model keys | [BYOK](./byok.md) | + +## Next Steps + +- **[BYOK guide](./byok.md)** — Use your own model provider keys +- **[Session Persistence](../session-persistence.md)** — Advanced session management +- **[Getting Started tutorial](../../getting-started.md)** — Build a complete app diff --git a/docs/guides/setup/byok.md b/docs/guides/setup/byok.md new file mode 100644 index 000000000..5b8b8a460 --- /dev/null +++ b/docs/guides/setup/byok.md @@ -0,0 +1,360 @@ +# BYOK (Bring Your Own Key) Setup + +Use your own model provider API keys instead of GitHub Copilot authentication. You control the identity layer, the model provider, and the billing — the SDK provides the agent runtime. + +**Best for:** Apps where users don't have GitHub accounts, enterprise deployments with existing model provider contracts, apps needing full control over identity and billing. + +## How It Works + +With BYOK, the SDK uses the Copilot CLI as an agent runtime only — it doesn't call GitHub's Copilot API. Instead, model requests go directly to your configured provider (OpenAI, Azure AI Foundry, Anthropic, etc.). + +```mermaid +flowchart LR + subgraph App["Your Application"] + SDK["SDK Client"] + IdP["Your Identity
Provider"] + end + + subgraph CLI["Copilot CLI"] + Runtime["Agent Runtime"] + end + + subgraph Provider["Your Model Provider"] + API["OpenAI / Azure /
Anthropic / Ollama"] + end + + IdP -.->|"authenticates
users"| SDK + SDK --> Runtime + Runtime -- "API key" --> API + + style App fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 + style CLI fill:#0d1117,stroke:#3fb950,color:#c9d1d9 + style Provider fill:#161b22,stroke:#f0883e,color:#c9d1d9 +``` + +**Key characteristics:** +- No GitHub Copilot subscription needed +- No GitHub account needed for end users +- You manage authentication and identity yourself +- Model requests go to your provider, billed to your account +- Full agent runtime capabilities (tools, sessions, streaming) still work + +## Architecture: GitHub Auth vs. BYOK + +```mermaid +flowchart TB + subgraph GitHub["GitHub Auth Path"] + direction LR + G1["User"] --> G2["GitHub OAuth"] + G2 --> G3["SDK + CLI"] + G3 --> G4["☁️ Copilot API"] + end + + subgraph BYOK["BYOK Path"] + direction LR + B1["User"] --> B2["Your Auth"] + B2 --> B3["SDK + CLI"] + B3 --> B4["☁️ Your Provider"] + end + + style GitHub fill:#161b22,stroke:#8b949e,color:#c9d1d9 + style BYOK fill:#0d1117,stroke:#3fb950,color:#c9d1d9 +``` + +## Quick Start + +
+Node.js / TypeScript + +```typescript +import { CopilotClient } from "@github/copilot-sdk"; + +const client = new CopilotClient(); + +const session = await client.createSession({ + model: "gpt-4.1", + provider: { + type: "openai", + baseUrl: "https://api.openai.com/v1", + apiKey: process.env.OPENAI_API_KEY, + }, +}); + +const response = await session.sendAndWait({ prompt: "Hello!" }); +console.log(response?.data.content); + +await client.stop(); +``` + +
+ +
+Python + +```python +import os +from copilot import CopilotClient + +client = CopilotClient() +await client.start() + +session = await client.create_session({ + "model": "gpt-4.1", + "provider": { + "type": "openai", + "base_url": "https://api.openai.com/v1", + "api_key": os.environ["OPENAI_API_KEY"], + }, +}) + +response = await session.send_and_wait({"prompt": "Hello!"}) +print(response.data.content) + +await client.stop() +``` + +
+ +
+Go + + +```go +client := copilot.NewClient(nil) +client.Start(ctx) +defer client.Stop() + +session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "gpt-4.1", + Provider: &copilot.ProviderConfig{ + Type: "openai", + BaseURL: "https://api.openai.com/v1", + APIKey: os.Getenv("OPENAI_API_KEY"), + }, +}) + +response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: "Hello!"}) +fmt.Println(*response.Data.Content) +``` + +
+ +
+.NET + +```csharp +await using var client = new CopilotClient(); +await using var session = await client.CreateSessionAsync(new SessionConfig +{ + Model = "gpt-4.1", + Provider = new ProviderConfig + { + Type = "openai", + BaseUrl = "https://api.openai.com/v1", + ApiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY"), + }, +}); + +var response = await session.SendAndWaitAsync( + new MessageOptions { Prompt = "Hello!" }); +Console.WriteLine(response?.Data.Content); +``` + +
+ +## Provider Configurations + +### OpenAI + +```typescript +provider: { + type: "openai", + baseUrl: "https://api.openai.com/v1", + apiKey: process.env.OPENAI_API_KEY, +} +``` + +### Azure AI Foundry + +```typescript +provider: { + type: "openai", + baseUrl: "https://your-resource.openai.azure.com/openai/v1/", + apiKey: process.env.FOUNDRY_API_KEY, + wireApi: "responses", // For GPT-5 series models +} +``` + +### Azure OpenAI (Native) + +```typescript +provider: { + type: "azure", + baseUrl: "https://your-resource.openai.azure.com", + apiKey: process.env.AZURE_OPENAI_KEY, + azure: { apiVersion: "2024-10-21" }, +} +``` + +### Anthropic + +```typescript +provider: { + type: "anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: process.env.ANTHROPIC_API_KEY, +} +``` + +### Ollama (Local) + +```typescript +provider: { + type: "openai", + baseUrl: "http://localhost:11434/v1", + // No API key needed for local Ollama +} +``` + +## Managing Identity Yourself + +With BYOK, you're responsible for authentication. Here are common patterns: + +### Pattern 1: Your Own Identity Provider + +```mermaid +sequenceDiagram + participant User + participant App as Your App + participant IdP as Your Identity Provider + participant SDK as SDK + CLI + participant LLM as Model Provider + + User->>App: Login + App->>IdP: Authenticate user + IdP-->>App: User identity + permissions + + App->>App: Look up API key for user's tier + App->>SDK: Create session (with provider config) + SDK->>LLM: Model request (your API key) + LLM-->>SDK: Response + SDK-->>App: Result + App-->>User: Display +``` + +```typescript +// Your app handles auth, then creates sessions with your API key +app.post("/chat", authMiddleware, async (req, res) => { + const user = req.user; // From your auth middleware + + // Use your API key — not the user's + const session = await getOrCreateSession(user.id, { + model: getModelForTier(user.tier), // "gpt-4.1" for pro, etc. + provider: { + type: "openai", + baseUrl: "https://api.openai.com/v1", + apiKey: process.env.OPENAI_API_KEY, // Your key, your billing + }, + }); + + const response = await session.sendAndWait({ prompt: req.body.message }); + res.json({ content: response?.data.content }); +}); +``` + +### Pattern 2: Per-Customer API Keys + +For B2B apps where each customer brings their own model provider keys: + +```mermaid +flowchart TB + subgraph Customers + C1["Customer A
(OpenAI key)"] + C2["Customer B
(Azure key)"] + C3["Customer C
(Anthropic key)"] + end + + subgraph App["Your App"] + Router["Request Router"] + KS["Key Store
(encrypted)"] + end + + C1 --> Router + C2 --> Router + C3 --> Router + + Router --> KS + KS --> SDK1["SDK → OpenAI"] + KS --> SDK2["SDK → Azure"] + KS --> SDK3["SDK → Anthropic"] + + style App fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 +``` + +```typescript +async function createSessionForCustomer(customerId: string) { + const config = await keyStore.getProviderConfig(customerId); + + return client.createSession({ + sessionId: `customer-${customerId}-${Date.now()}`, + model: config.model, + provider: { + type: config.providerType, + baseUrl: config.baseUrl, + apiKey: config.apiKey, + }, + }); +} +``` + +## Session Persistence with BYOK + +When resuming BYOK sessions, you **must** re-provide the provider configuration. API keys are never persisted to disk for security. + +```typescript +// Create session +const session = await client.createSession({ + sessionId: "task-123", + model: "gpt-4.1", + provider: { + type: "openai", + baseUrl: "https://api.openai.com/v1", + apiKey: process.env.OPENAI_API_KEY, + }, +}); + +// Resume later — must re-provide provider config +const resumed = await client.resumeSession("task-123", { + provider: { + type: "openai", + baseUrl: "https://api.openai.com/v1", + apiKey: process.env.OPENAI_API_KEY, // Required again + }, +}); +``` + +## Limitations + +| Limitation | Details | +|------------|---------| +| **Static credentials only** | API keys or bearer tokens — no native Entra ID, OIDC, or managed identity support. See [Azure Managed Identity workaround](./azure-managed-identity.md) for using `DefaultAzureCredential` with short-lived tokens. | +| **No auto-refresh** | If a bearer token expires, you must create a new session | +| **Your billing** | All model usage is billed to your provider account | +| **Model availability** | Limited to what your provider offers | +| **Keys not persisted** | Must re-provide on session resume | + +For the full BYOK reference, see the **[BYOK documentation](../../auth/byok.md)**. + +## When to Move On + +| Need | Next Guide | +|------|-----------| +| Run the SDK on a server | [Backend Services](./backend-services.md) | +| Multiple users with GitHub accounts | [GitHub OAuth](./github-oauth.md) | +| Handle many concurrent users | [Scaling & Multi-Tenancy](./scaling.md) | + +## Next Steps + +- **[BYOK reference](../../auth/byok.md)** — Full provider config details and troubleshooting +- **[Backend Services](./backend-services.md)** — Deploy the SDK server-side +- **[Scaling & Multi-Tenancy](./scaling.md)** — Serve many customers at scale diff --git a/docs/guides/setup/github-oauth.md b/docs/guides/setup/github-oauth.md new file mode 100644 index 000000000..07251c8fb --- /dev/null +++ b/docs/guides/setup/github-oauth.md @@ -0,0 +1,385 @@ +# GitHub OAuth Setup + +Let users authenticate with their GitHub accounts to use Copilot through your application. This supports individual accounts, organization memberships, and enterprise identities. + +**Best for:** Multi-user apps, internal tools with org access control, SaaS products, apps where users have GitHub accounts. + +## How It Works + +You create a GitHub OAuth App (or GitHub App), users authorize it, and you pass their access token to the SDK. Copilot requests are made on behalf of each authenticated user, using their Copilot subscription. + +```mermaid +sequenceDiagram + participant User + participant App as Your App + participant GH as GitHub + participant SDK as SDK Client + participant CLI as Copilot CLI + participant API as Copilot API + + User->>App: Click "Sign in with GitHub" + App->>GH: Redirect to OAuth authorize + GH->>User: "Authorize this app?" + User->>GH: Approve + GH->>App: Authorization code + App->>GH: Exchange code for token + GH-->>App: Access token (gho_xxx) + + App->>SDK: Create client with token + SDK->>CLI: Start with githubToken + CLI->>API: Request (as user) + API-->>CLI: Response + CLI-->>SDK: Result + SDK-->>App: Display to user +``` + +**Key characteristics:** +- Each user authenticates with their own GitHub account +- Copilot usage is billed to each user's subscription +- Supports GitHub organizations and enterprise accounts +- Your app never handles model API keys — GitHub manages everything + +## Architecture + +```mermaid +flowchart TB + subgraph Users["Users"] + U1["👤 User A
(Org Member)"] + U2["👤 User B
(Enterprise)"] + U3["👤 User C
(Personal)"] + end + + subgraph App["Your Application"] + OAuth["OAuth Flow"] + TokenStore["Token Store"] + SDK["SDK Client(s)"] + end + + subgraph CLI["Copilot CLI"] + RPC["JSON-RPC"] + end + + U1 --> OAuth + U2 --> OAuth + U3 --> OAuth + OAuth --> TokenStore + TokenStore --> SDK + SDK --> RPC + RPC --> Copilot["☁️ GitHub Copilot"] + + style Users fill:#161b22,stroke:#8b949e,color:#c9d1d9 + style App fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 + style CLI fill:#0d1117,stroke:#3fb950,color:#c9d1d9 +``` + +## Step 1: Create a GitHub OAuth App + +1. Go to **GitHub Settings → Developer Settings → OAuth Apps → New OAuth App** + (or for organizations: **Organization Settings → Developer Settings**) + +2. Fill in: + - **Application name**: Your app's name + - **Homepage URL**: Your app's URL + - **Authorization callback URL**: Your OAuth callback endpoint (e.g., `https://yourapp.com/auth/callback`) + +3. Note your **Client ID** and generate a **Client Secret** + +> **GitHub App vs OAuth App:** Both work. GitHub Apps offer finer-grained permissions and are recommended for new projects. OAuth Apps are simpler to set up. The token flow is the same from the SDK's perspective. + +## Step 2: Implement the OAuth Flow + +Your application handles the standard GitHub OAuth flow. Here's the server-side token exchange: + +```typescript +// Server-side: Exchange authorization code for user token +async function handleOAuthCallback(code: string): Promise { + const response = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: process.env.GITHUB_CLIENT_ID, + client_secret: process.env.GITHUB_CLIENT_SECRET, + code, + }), + }); + + const data = await response.json(); + return data.access_token; // gho_xxxx or ghu_xxxx +} +``` + +## Step 3: Pass the Token to the SDK + +Create a SDK client for each authenticated user, passing their token: + +
+Node.js / TypeScript + +```typescript +import { CopilotClient } from "@github/copilot-sdk"; + +// Create a client for an authenticated user +function createClientForUser(userToken: string): CopilotClient { + return new CopilotClient({ + githubToken: userToken, + useLoggedInUser: false, // Don't fall back to CLI login + }); +} + +// Usage +const client = createClientForUser("gho_user_access_token"); +const session = await client.createSession({ + sessionId: `user-${userId}-session`, + model: "gpt-4.1", +}); + +const response = await session.sendAndWait({ prompt: "Hello!" }); +``` + +
+ +
+Python + +```python +from copilot import CopilotClient + +def create_client_for_user(user_token: str) -> CopilotClient: + return CopilotClient({ + "github_token": user_token, + "use_logged_in_user": False, + }) + +# Usage +client = create_client_for_user("gho_user_access_token") +await client.start() + +session = await client.create_session({ + "session_id": f"user-{user_id}-session", + "model": "gpt-4.1", +}) + +response = await session.send_and_wait({"prompt": "Hello!"}) +``` + +
+ +
+Go + + +```go +func createClientForUser(userToken string) *copilot.Client { + return copilot.NewClient(&copilot.ClientOptions{ + GithubToken: userToken, + UseLoggedInUser: copilot.Bool(false), + }) +} + +// Usage +client := createClientForUser("gho_user_access_token") +client.Start(ctx) +defer client.Stop() + +session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ + SessionID: fmt.Sprintf("user-%s-session", userID), + Model: "gpt-4.1", +}) +response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: "Hello!"}) +``` + +
+ +
+.NET + + +```csharp +CopilotClient CreateClientForUser(string userToken) => + new CopilotClient(new CopilotClientOptions + { + GithubToken = userToken, + UseLoggedInUser = false, + }); + +// Usage +await using var client = CreateClientForUser("gho_user_access_token"); +await using var session = await client.CreateSessionAsync(new SessionConfig +{ + SessionId = $"user-{userId}-session", + Model = "gpt-4.1", +}); + +var response = await session.SendAndWaitAsync( + new MessageOptions { Prompt = "Hello!" }); +``` + +
+ +## Enterprise & Organization Access + +GitHub OAuth naturally supports enterprise scenarios. When users authenticate with GitHub, their org memberships and enterprise associations come along. + +```mermaid +flowchart TB + subgraph Enterprise["GitHub Enterprise"] + Org1["Org: Engineering"] + Org2["Org: Data Science"] + end + + subgraph Users + U1["👤 Alice
(Engineering)"] + U2["👤 Bob
(Data Science)"] + end + + U1 -.->|member| Org1 + U2 -.->|member| Org2 + + subgraph App["Your Internal App"] + OAuth["OAuth + Org Check"] + SDK["SDK Client"] + end + + U1 --> OAuth + U2 --> OAuth + OAuth -->|"Verify org membership"| GH["GitHub API"] + OAuth --> SDK + + style Enterprise fill:#161b22,stroke:#f0883e,color:#c9d1d9 + style App fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 +``` + +### Verify Organization Membership + +After OAuth, check that the user belongs to your organization: + +```typescript +async function verifyOrgMembership( + token: string, + requiredOrg: string +): Promise { + const response = await fetch("https://api.github.com/user/orgs", { + headers: { Authorization: `Bearer ${token}` }, + }); + const orgs = await response.json(); + return orgs.some((org: any) => org.login === requiredOrg); +} + +// In your auth flow +const token = await handleOAuthCallback(code); +if (!await verifyOrgMembership(token, "my-company")) { + throw new Error("User is not a member of the required organization"); +} +const client = createClientForUser(token); +``` + +### Enterprise Managed Users (EMU) + +For GitHub Enterprise Managed Users, the flow is identical — EMU users authenticate through GitHub OAuth like any other user. Their enterprise policies (IP restrictions, SAML SSO) are enforced by GitHub automatically. + +```typescript +// No special SDK configuration needed for EMU +// Enterprise policies are enforced server-side by GitHub +const client = new CopilotClient({ + githubToken: emuUserToken, // Works the same as regular tokens + useLoggedInUser: false, +}); +``` + +## Supported Token Types + +| Token Prefix | Source | Works? | +|-------------|--------|--------| +| `gho_` | OAuth user access token | ✅ | +| `ghu_` | GitHub App user access token | ✅ | +| `github_pat_` | Fine-grained personal access token | ✅ | +| `ghp_` | Classic personal access token | ❌ (deprecated) | + +## Token Lifecycle + +```mermaid +flowchart LR + A["User authorizes"] --> B["Token issued
(gho_xxx)"] + B --> C{"Token valid?"} + C -->|Yes| D["SDK uses token"] + C -->|No| E["Refresh or
re-authorize"] + E --> B + D --> F{"User revokes
or token expires?"} + F -->|Yes| E + F -->|No| D + + style A fill:#0d1117,stroke:#3fb950,color:#c9d1d9 + style E fill:#0d1117,stroke:#f0883e,color:#c9d1d9 +``` + +**Important:** Your application is responsible for token storage, refresh, and expiration handling. The SDK uses whatever token you provide — it doesn't manage the OAuth lifecycle. + +### Token Refresh Pattern + +```typescript +async function getOrRefreshToken(userId: string): Promise { + const stored = await tokenStore.get(userId); + + if (stored && !isExpired(stored)) { + return stored.accessToken; + } + + if (stored?.refreshToken) { + const refreshed = await refreshGitHubToken(stored.refreshToken); + await tokenStore.set(userId, refreshed); + return refreshed.accessToken; + } + + throw new Error("User must re-authenticate"); +} +``` + +## Multi-User Patterns + +### One Client Per User (Recommended) + +Each user gets their own SDK client with their own token. This provides the strongest isolation. + +```typescript +const clients = new Map(); + +function getClientForUser(userId: string, token: string): CopilotClient { + if (!clients.has(userId)) { + clients.set(userId, new CopilotClient({ + githubToken: token, + useLoggedInUser: false, + })); + } + return clients.get(userId)!; +} +``` + +### Shared CLI with Per-Request Tokens + +For a lighter resource footprint, you can run a single external CLI server and pass tokens per session. See [Backend Services](./backend-services.md) for this pattern. + +## Limitations + +| Limitation | Details | +|------------|---------| +| **Copilot subscription required** | Each user needs an active Copilot subscription | +| **Token management is your responsibility** | Store, refresh, and handle expiration | +| **GitHub account required** | Users must have GitHub accounts | +| **Rate limits per user** | Subject to each user's Copilot rate limits | + +## When to Move On + +| Need | Next Guide | +|------|-----------| +| Users without GitHub accounts | [BYOK](./byok.md) | +| Run the SDK on servers | [Backend Services](./backend-services.md) | +| Handle many concurrent users | [Scaling & Multi-Tenancy](./scaling.md) | + +## Next Steps + +- **[Authentication docs](../../auth/index.md)** — Full auth method reference +- **[Backend Services](./backend-services.md)** — Run the SDK server-side +- **[Scaling & Multi-Tenancy](./scaling.md)** — Handle many users at scale diff --git a/docs/guides/setup/index.md b/docs/guides/setup/index.md new file mode 100644 index 000000000..2613fe29d --- /dev/null +++ b/docs/guides/setup/index.md @@ -0,0 +1,143 @@ +# Setup Guides + +These guides walk you through configuring the Copilot SDK for your specific use case — from personal side projects to production platforms serving thousands of users. + +## Architecture at a Glance + +Every Copilot SDK integration follows the same core pattern: your application talks to the SDK, which communicates with the Copilot CLI over JSON-RPC. What changes across setups is **where the CLI runs**, **how users authenticate**, and **how sessions are managed**. + +```mermaid +flowchart TB + subgraph YourApp["Your Application"] + SDK["SDK Client"] + end + + subgraph CLI["Copilot CLI"] + direction TB + RPC["JSON-RPC Server"] + Auth["Authentication"] + Sessions["Session Manager"] + Models["Model Provider"] + end + + SDK -- "JSON-RPC
(stdio or TCP)" --> RPC + RPC --> Auth + RPC --> Sessions + Auth --> Models + + style YourApp fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 + style CLI fill:#161b22,stroke:#3fb950,color:#c9d1d9 +``` + +The setup guides below help you configure each layer for your scenario. + +## Who Are You? + +### 🧑‍💻 Hobbyist + +You're building a personal assistant, side project, or experimental app. You want the simplest path to getting Copilot in your code. + +**Start with:** +1. **[Local CLI](./local-cli.md)** — Use the CLI already signed in on your machine +2. **[Bundled CLI](./bundled-cli.md)** — Package everything into a standalone app + +### 🏢 Internal App Developer + +You're building tools for your team or company. Users are employees who need to authenticate with their enterprise GitHub accounts or org memberships. + +**Start with:** +1. **[GitHub OAuth](./github-oauth.md)** — Let employees sign in with their GitHub accounts +2. **[Backend Services](./backend-services.md)** — Run the SDK in your internal services + +**If scaling beyond a single server:** +3. **[Scaling & Multi-Tenancy](./scaling.md)** — Handle multiple users and services + +### 🚀 App Developer (ISV) + +You're building a product for customers. You need to handle authentication for your users — either through GitHub or by managing identity yourself. + +**Start with:** +1. **[GitHub OAuth](./github-oauth.md)** — Let customers sign in with GitHub +2. **[BYOK](./byok.md)** — Manage identity yourself with your own model keys +3. **[Backend Services](./backend-services.md)** — Power your product from server-side code + +**For production:** +4. **[Scaling & Multi-Tenancy](./scaling.md)** — Serve many customers reliably + +### 🏗️ Platform Developer + +You're embedding Copilot into a platform — APIs, developer tools, or infrastructure that other developers build on. You need fine-grained control over sessions, scaling, and multi-tenancy. + +**Start with:** +1. **[Backend Services](./backend-services.md)** — Core server-side integration +2. **[Scaling & Multi-Tenancy](./scaling.md)** — Session isolation, horizontal scaling, persistence + +**Depending on your auth model:** +3. **[GitHub OAuth](./github-oauth.md)** — For GitHub-authenticated users +4. **[BYOK](./byok.md)** — For self-managed identity and model access + +## Decision Matrix + +Use this table to find the right guides based on what you need to do: + +| What you need | Guide | +|---------------|-------| +| Simplest possible setup | [Local CLI](./local-cli.md) | +| Ship a standalone app with Copilot | [Bundled CLI](./bundled-cli.md) | +| Users sign in with GitHub | [GitHub OAuth](./github-oauth.md) | +| Use your own model keys (OpenAI, Azure, etc.) | [BYOK](./byok.md) | +| Azure BYOK with Managed Identity (no API keys) | [Azure Managed Identity](./azure-managed-identity.md) | +| Run the SDK on a server | [Backend Services](./backend-services.md) | +| Serve multiple users / scale horizontally | [Scaling & Multi-Tenancy](./scaling.md) | + +## Configuration Comparison + +```mermaid +flowchart LR + subgraph Auth["Authentication"] + A1["Signed-in CLI
(local)"] + A2["GitHub OAuth
(multi-user)"] + A3["Env Vars / Tokens
(server)"] + A4["BYOK
(your keys)"] + end + + subgraph Deploy["Deployment"] + D1["Local Process
(auto-managed)"] + D2["Bundled Binary
(shipped with app)"] + D3["External Server
(headless CLI)"] + end + + subgraph Scale["Scaling"] + S1["Single User
(one CLI)"] + S2["Multi-User
(shared CLI)"] + S3["Isolated
(CLI per user)"] + end + + A1 --> D1 --> S1 + A2 --> D3 --> S2 + A3 --> D3 --> S2 + A4 --> D2 --> S1 + A2 --> D3 --> S3 + A3 --> D3 --> S3 + + style Auth fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 + style Deploy fill:#0d1117,stroke:#3fb950,color:#c9d1d9 + style Scale fill:#0d1117,stroke:#f0883e,color:#c9d1d9 +``` + +## Prerequisites + +All guides assume you have: + +- **Copilot CLI** installed ([Installation guide](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)) +- **One of the SDKs** installed: + - Node.js: `npm install @github/copilot-sdk` + - Python: `pip install github-copilot-sdk` + - Go: `go get github.com/github/copilot-sdk/go` + - .NET: `dotnet add package GitHub.Copilot.SDK` + +If you're brand new, start with the **[Getting Started tutorial](../../getting-started.md)** first, then come back here for production configuration. + +## Next Steps + +Pick the guide that matches your situation from the [decision matrix](#decision-matrix) above, or start with the persona description closest to your role. diff --git a/docs/guides/setup/local-cli.md b/docs/guides/setup/local-cli.md new file mode 100644 index 000000000..a5fa906b8 --- /dev/null +++ b/docs/guides/setup/local-cli.md @@ -0,0 +1,208 @@ +# Local CLI Setup + +Use the Copilot SDK with the CLI already signed in on your machine. This is the simplest configuration — zero auth code, zero infrastructure. + +**Best for:** Personal projects, prototyping, local development, learning the SDK. + +## How It Works + +When you install the Copilot CLI and sign in, your credentials are stored in the system keychain. The SDK automatically starts the CLI as a child process and uses those stored credentials. + +```mermaid +flowchart LR + subgraph YourMachine["Your Machine"] + App["Your App"] --> SDK["SDK Client"] + SDK -- "stdio" --> CLI["Copilot CLI
(auto-started)"] + CLI --> Keychain["🔐 System Keychain
(stored credentials)"] + end + CLI -- "API calls" --> Copilot["☁️ GitHub Copilot"] + + style YourMachine fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 +``` + +**Key characteristics:** +- CLI is spawned automatically by the SDK (no setup needed) +- Authentication uses the signed-in user's credentials from the system keychain +- Communication happens over stdio (stdin/stdout) — no network ports +- Sessions are local to your machine + +## Quick Start + +The default configuration requires no options at all: + +
+Node.js / TypeScript + +```typescript +import { CopilotClient } from "@github/copilot-sdk"; + +const client = new CopilotClient(); +const session = await client.createSession({ model: "gpt-4.1" }); + +const response = await session.sendAndWait({ prompt: "Hello!" }); +console.log(response?.data.content); + +await client.stop(); +``` + +
+ +
+Python + +```python +from copilot import CopilotClient + +client = CopilotClient() +await client.start() + +session = await client.create_session({"model": "gpt-4.1"}) +response = await session.send_and_wait({"prompt": "Hello!"}) +print(response.data.content) + +await client.stop() +``` + +
+ +
+Go + + +```go +client := copilot.NewClient(nil) +if err := client.Start(ctx); err != nil { + log.Fatal(err) +} +defer client.Stop() + +session, _ := client.CreateSession(ctx, &copilot.SessionConfig{Model: "gpt-4.1"}) +response, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: "Hello!"}) +fmt.Println(*response.Data.Content) +``` + +
+ +
+.NET + +```csharp +await using var client = new CopilotClient(); +await using var session = await client.CreateSessionAsync( + new SessionConfig { Model = "gpt-4.1" }); + +var response = await session.SendAndWaitAsync( + new MessageOptions { Prompt = "Hello!" }); +Console.WriteLine(response?.Data.Content); +``` + +
+ +That's it. The SDK handles everything: starting the CLI, authenticating, and managing the session. + +## What's Happening Under the Hood + +```mermaid +sequenceDiagram + participant App as Your App + participant SDK as SDK Client + participant CLI as Copilot CLI + participant GH as GitHub API + + App->>SDK: new CopilotClient() + Note over SDK: Locates CLI binary + + App->>SDK: createSession() + SDK->>CLI: Spawn process (stdio) + CLI->>CLI: Load credentials from keychain + CLI->>GH: Authenticate + GH-->>CLI: ✅ Valid session + CLI-->>SDK: Session created + SDK-->>App: Session ready + + App->>SDK: sendAndWait("Hello!") + SDK->>CLI: JSON-RPC request + CLI->>GH: Model API call + GH-->>CLI: Response + CLI-->>SDK: JSON-RPC response + SDK-->>App: Response data +``` + +## Configuration Options + +While defaults work great, you can customize the local setup: + +```typescript +const client = new CopilotClient({ + // Override CLI location (default: bundled with @github/copilot) + cliPath: "/usr/local/bin/copilot", + + // Set log level for debugging + logLevel: "debug", + + // Pass extra CLI arguments + cliArgs: ["--disable-telemetry"], + + // Set working directory + cwd: "/path/to/project", + + // Auto-restart CLI if it crashes (default: true) + autoRestart: true, +}); +``` + +## Using Environment Variables + +Instead of the keychain, you can authenticate via environment variables. This is useful for CI or when you don't want interactive login. + +```bash +# Set one of these (in priority order): +export COPILOT_GITHUB_TOKEN="gho_xxxx" # Recommended +export GH_TOKEN="gho_xxxx" # GitHub CLI compatible +export GITHUB_TOKEN="gho_xxxx" # GitHub Actions compatible +``` + +The SDK picks these up automatically — no code changes needed. + +## Managing Sessions + +With the local CLI, sessions default to ephemeral. To create resumable sessions, provide your own session ID: + +```typescript +// Create a named session +const session = await client.createSession({ + sessionId: "my-project-analysis", + model: "gpt-4.1", +}); + +// Later, resume it +const resumed = await client.resumeSession("my-project-analysis"); +``` + +Session state is stored locally at `~/.copilot/session-state/{sessionId}/`. + +## Limitations + +| Limitation | Details | +|------------|---------| +| **Single user** | Credentials are tied to whoever signed in to the CLI | +| **Local only** | The CLI runs on the same machine as your app | +| **No multi-tenant** | Can't serve multiple users from one CLI instance | +| **Requires CLI login** | User must run `copilot` and authenticate first | + +## When to Move On + +If you need any of these, it's time to pick a more advanced setup: + +| Need | Next Guide | +|------|-----------| +| Ship your app to others | [Bundled CLI](./bundled-cli.md) | +| Multiple users signing in | [GitHub OAuth](./github-oauth.md) | +| Run on a server | [Backend Services](./backend-services.md) | +| Use your own model keys | [BYOK](./byok.md) | + +## Next Steps + +- **[Getting Started tutorial](../../getting-started.md)** — Build a complete interactive app +- **[Authentication docs](../../auth/index.md)** — All auth methods in detail +- **[Session Persistence](../session-persistence.md)** — Advanced session management diff --git a/docs/guides/setup/scaling.md b/docs/guides/setup/scaling.md new file mode 100644 index 000000000..fcdb716da --- /dev/null +++ b/docs/guides/setup/scaling.md @@ -0,0 +1,635 @@ +# Scaling & Multi-Tenancy + +Design your Copilot SDK deployment to serve multiple users, handle concurrent sessions, and scale horizontally across infrastructure. This guide covers session isolation patterns, scaling topologies, and production best practices. + +**Best for:** Platform developers, SaaS builders, any deployment serving more than a handful of concurrent users. + +## Core Concepts + +Before choosing a pattern, understand three dimensions of scaling: + +```mermaid +flowchart TB + subgraph Dimensions["Scaling Dimensions"] + direction LR + I["🔒 Isolation
Who sees what?"] + C["⚡ Concurrency
How many at once?"] + P["💾 Persistence
How long do sessions live?"] + end + + I --> I1["Shared CLI
vs. CLI per user"] + C --> C1["Session pooling
vs. on-demand"] + P --> P1["Ephemeral
vs. persistent"] + + style Dimensions fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 +``` + +## Session Isolation Patterns + +### Pattern 1: Isolated CLI Per User + +Each user gets their own CLI server instance. Strongest isolation — a user's sessions, memory, and processes are completely separated. + +```mermaid +flowchart TB + LB["Load Balancer"] + + subgraph User_A["User A"] + SDK_A["SDK Client"] --> CLI_A["CLI Server A
:4321"] + CLI_A --> SA["📁 Sessions A"] + end + + subgraph User_B["User B"] + SDK_B["SDK Client"] --> CLI_B["CLI Server B
:4322"] + CLI_B --> SB["📁 Sessions B"] + end + + subgraph User_C["User C"] + SDK_C["SDK Client"] --> CLI_C["CLI Server C
:4323"] + CLI_C --> SC["📁 Sessions C"] + end + + LB --> SDK_A + LB --> SDK_B + LB --> SDK_C + + style User_A fill:#0d1117,stroke:#3fb950,color:#c9d1d9 + style User_B fill:#0d1117,stroke:#3fb950,color:#c9d1d9 + style User_C fill:#0d1117,stroke:#3fb950,color:#c9d1d9 +``` + +**When to use:** +- Multi-tenant SaaS where data isolation is critical +- Users with different auth credentials +- Compliance requirements (SOC 2, HIPAA) + +```typescript +// CLI pool manager — one CLI per user +class CLIPool { + private instances = new Map(); + private nextPort = 5000; + + async getClientForUser(userId: string, token?: string): Promise { + if (this.instances.has(userId)) { + return this.instances.get(userId)!.client; + } + + const port = this.nextPort++; + + // Spawn a dedicated CLI for this user + await spawnCLI(port, token); + + const client = new CopilotClient({ + cliUrl: `localhost:${port}`, + }); + + this.instances.set(userId, { client, port }); + return client; + } + + async releaseUser(userId: string): Promise { + const instance = this.instances.get(userId); + if (instance) { + await instance.client.stop(); + this.instances.delete(userId); + } + } +} +``` + +### Pattern 2: Shared CLI with Session Isolation + +Multiple users share one CLI server but have isolated sessions via unique session IDs. Lighter on resources, but weaker isolation. + +```mermaid +flowchart TB + U1["👤 User A"] + U2["👤 User B"] + U3["👤 User C"] + + subgraph App["Your App"] + Router["Session Router"] + end + + subgraph CLI["Shared CLI Server :4321"] + SA["Session: user-a-chat"] + SB["Session: user-b-chat"] + SC["Session: user-c-chat"] + end + + U1 --> Router + U2 --> Router + U3 --> Router + + Router --> SA + Router --> SB + Router --> SC + + style App fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 + style CLI fill:#0d1117,stroke:#3fb950,color:#c9d1d9 +``` + +**When to use:** +- Internal tools with trusted users +- Resource-constrained environments +- Lower isolation requirements + +```typescript +const sharedClient = new CopilotClient({ + cliUrl: "localhost:4321", +}); + +// Enforce session isolation through naming conventions +function getSessionId(userId: string, purpose: string): string { + return `${userId}-${purpose}-${Date.now()}`; +} + +// Access control: ensure users can only access their own sessions +async function resumeSessionWithAuth( + sessionId: string, + currentUserId: string +): Promise { + const [sessionUserId] = sessionId.split("-"); + if (sessionUserId !== currentUserId) { + throw new Error("Access denied: session belongs to another user"); + } + return sharedClient.resumeSession(sessionId); +} +``` + +### Pattern 3: Shared Sessions (Collaborative) + +Multiple users interact with the same session — like a shared chat room with Copilot. + +```mermaid +flowchart TB + U1["👤 Alice"] + U2["👤 Bob"] + U3["👤 Carol"] + + subgraph App["Collaboration Layer"] + Queue["Message Queue
(serialize access)"] + Lock["Session Lock"] + end + + subgraph CLI["CLI Server"] + Session["Shared Session:
team-project-review"] + end + + U1 --> Queue + U2 --> Queue + U3 --> Queue + + Queue --> Lock + Lock --> Session + + style App fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 + style CLI fill:#0d1117,stroke:#3fb950,color:#c9d1d9 +``` + +**When to use:** +- Team collaboration tools +- Shared code review sessions +- Pair programming assistants + +> ⚠️ **Important:** The SDK doesn't provide built-in session locking. You **must** serialize access to prevent concurrent writes to the same session. + +```typescript +import Redis from "ioredis"; + +const redis = new Redis(); + +async function withSessionLock( + sessionId: string, + fn: () => Promise, + timeoutSec = 300 +): Promise { + const lockKey = `session-lock:${sessionId}`; + const lockId = crypto.randomUUID(); + + // Acquire lock + const acquired = await redis.set(lockKey, lockId, "NX", "EX", timeoutSec); + if (!acquired) { + throw new Error("Session is in use by another user"); + } + + try { + return await fn(); + } finally { + // Release lock (only if we still own it) + const currentLock = await redis.get(lockKey); + if (currentLock === lockId) { + await redis.del(lockKey); + } + } +} + +// Usage: serialize access to shared session +app.post("/team-chat", authMiddleware, async (req, res) => { + const result = await withSessionLock("team-project-review", async () => { + const session = await client.resumeSession("team-project-review"); + return session.sendAndWait({ prompt: req.body.message }); + }); + + res.json({ content: result?.data.content }); +}); +``` + +## Comparison of Isolation Patterns + +| | Isolated CLI Per User | Shared CLI + Session Isolation | Shared Sessions | +|---|---|---|---| +| **Isolation** | ✅ Complete | ⚠️ Logical | ❌ Shared | +| **Resource usage** | High (CLI per user) | Low (one CLI) | Low (one CLI + session) | +| **Complexity** | Medium | Low | High (locking) | +| **Auth flexibility** | ✅ Per-user tokens | ⚠️ Service token | ⚠️ Service token | +| **Best for** | Multi-tenant SaaS | Internal tools | Collaboration | + +## Horizontal Scaling + +### Multiple CLI Servers Behind a Load Balancer + +```mermaid +flowchart TB + Users["👥 Users"] --> LB["Load Balancer"] + + subgraph Pool["CLI Server Pool"] + CLI1["CLI Server 1
:4321"] + CLI2["CLI Server 2
:4322"] + CLI3["CLI Server 3
:4323"] + end + + subgraph Storage["Shared Storage"] + NFS["📁 Network File System
or Cloud Storage"] + end + + LB --> CLI1 + LB --> CLI2 + LB --> CLI3 + + CLI1 --> NFS + CLI2 --> NFS + CLI3 --> NFS + + style Pool fill:#0d1117,stroke:#3fb950,color:#c9d1d9 + style Storage fill:#161b22,stroke:#f0883e,color:#c9d1d9 +``` + +**Key requirement:** Session state must be on **shared storage** so any CLI server can resume any session. + +```typescript +// Route sessions to CLI servers +class CLILoadBalancer { + private servers: string[]; + private currentIndex = 0; + + constructor(servers: string[]) { + this.servers = servers; + } + + // Round-robin selection + getNextServer(): string { + const server = this.servers[this.currentIndex]; + this.currentIndex = (this.currentIndex + 1) % this.servers.length; + return server; + } + + // Sticky sessions: same user always hits same server + getServerForUser(userId: string): string { + const hash = this.hashCode(userId); + return this.servers[hash % this.servers.length]; + } + + private hashCode(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); + } +} + +const lb = new CLILoadBalancer([ + "cli-1:4321", + "cli-2:4321", + "cli-3:4321", +]); + +app.post("/chat", async (req, res) => { + const server = lb.getServerForUser(req.user.id); + const client = new CopilotClient({ cliUrl: server }); + + const session = await client.createSession({ + sessionId: `user-${req.user.id}-chat`, + model: "gpt-4.1", + }); + + const response = await session.sendAndWait({ prompt: req.body.message }); + res.json({ content: response?.data.content }); +}); +``` + +### Sticky Sessions vs. Shared Storage + +```mermaid +flowchart LR + subgraph Sticky["Sticky Sessions"] + direction TB + S1["User A → always CLI 1"] + S2["User B → always CLI 2"] + S3["✅ No shared storage needed"] + S4["❌ Uneven load if users vary"] + end + + subgraph Shared["Shared Storage"] + direction TB + SH1["User A → any CLI"] + SH2["User B → any CLI"] + SH3["✅ Even load distribution"] + SH4["❌ Requires NFS / cloud storage"] + end + + style Sticky fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 + style Shared fill:#0d1117,stroke:#3fb950,color:#c9d1d9 +``` + +**Sticky sessions** are simpler — pin users to specific CLI servers. No shared storage needed, but load distribution is uneven. + +**Shared storage** enables any CLI to handle any session. Better load distribution, but requires networked storage for `~/.copilot/session-state/`. + +## Vertical Scaling + +### Tuning a Single CLI Server + +A single CLI server can handle many concurrent sessions. Key considerations: + +```mermaid +flowchart TB + subgraph Resources["Resource Dimensions"] + CPU["🔧 CPU
Model request processing"] + MEM["💾 Memory
Active session state"] + DISK["💿 Disk I/O
Session persistence"] + NET["🌐 Network
API calls to provider"] + end + + style Resources fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 +``` + +**Session lifecycle management** is key to vertical scaling: + +```typescript +// Limit concurrent active sessions +class SessionManager { + private activeSessions = new Map(); + private maxConcurrent: number; + + constructor(maxConcurrent = 50) { + this.maxConcurrent = maxConcurrent; + } + + async getSession(sessionId: string): Promise { + // Return existing active session + if (this.activeSessions.has(sessionId)) { + return this.activeSessions.get(sessionId)!; + } + + // Enforce concurrency limit + if (this.activeSessions.size >= this.maxConcurrent) { + await this.evictOldestSession(); + } + + // Create or resume + const session = await client.createSession({ + sessionId, + model: "gpt-4.1", + }); + + this.activeSessions.set(sessionId, session); + return session; + } + + private async evictOldestSession(): Promise { + const [oldestId] = this.activeSessions.keys(); + const session = this.activeSessions.get(oldestId)!; + // Session state is persisted automatically — safe to destroy + await session.destroy(); + this.activeSessions.delete(oldestId); + } +} +``` + +## Ephemeral vs. Persistent Sessions + +```mermaid +flowchart LR + subgraph Ephemeral["Ephemeral Sessions"] + E1["Created per request"] + E2["Destroyed after use"] + E3["No state to manage"] + E4["Good for: one-shot tasks,
stateless APIs"] + end + + subgraph Persistent["Persistent Sessions"] + P1["Named session ID"] + P2["Survives restarts"] + P3["Resumable"] + P4["Good for: multi-turn chat,
long workflows"] + end + + style Ephemeral fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 + style Persistent fill:#0d1117,stroke:#3fb950,color:#c9d1d9 +``` + +### Ephemeral Sessions + +For stateless API endpoints where each request is independent: + +```typescript +app.post("/api/analyze", async (req, res) => { + const session = await client.createSession({ + model: "gpt-4.1", + }); + + try { + const response = await session.sendAndWait({ + prompt: req.body.prompt, + }); + res.json({ result: response?.data.content }); + } finally { + await session.destroy(); // Clean up immediately + } +}); +``` + +### Persistent Sessions + +For conversational interfaces or long-running workflows: + +```typescript +// Create a resumable session +app.post("/api/chat/start", async (req, res) => { + const sessionId = `user-${req.user.id}-${Date.now()}`; + + const session = await client.createSession({ + sessionId, + model: "gpt-4.1", + infiniteSessions: { + enabled: true, + backgroundCompactionThreshold: 0.80, + }, + }); + + res.json({ sessionId }); +}); + +// Continue the conversation +app.post("/api/chat/message", async (req, res) => { + const session = await client.resumeSession(req.body.sessionId); + const response = await session.sendAndWait({ prompt: req.body.message }); + + res.json({ content: response?.data.content }); +}); + +// Clean up when done +app.post("/api/chat/end", async (req, res) => { + await client.deleteSession(req.body.sessionId); + res.json({ success: true }); +}); +``` + +## Container Deployments + +### Kubernetes with Persistent Storage + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: copilot-cli +spec: + replicas: 3 + selector: + matchLabels: + app: copilot-cli + template: + metadata: + labels: + app: copilot-cli + spec: + containers: + - name: copilot-cli + image: ghcr.io/github/copilot-cli:latest + args: ["--headless", "--port", "4321"] + env: + - name: COPILOT_GITHUB_TOKEN + valueFrom: + secretKeyRef: + name: copilot-secrets + key: github-token + ports: + - containerPort: 4321 + volumeMounts: + - name: session-state + mountPath: /root/.copilot/session-state + volumes: + - name: session-state + persistentVolumeClaim: + claimName: copilot-sessions-pvc +--- +apiVersion: v1 +kind: Service +metadata: + name: copilot-cli +spec: + selector: + app: copilot-cli + ports: + - port: 4321 + targetPort: 4321 +``` + +```mermaid +flowchart TB + subgraph K8s["Kubernetes Cluster"] + Svc["Service: copilot-cli:4321"] + Pod1["Pod 1: CLI"] + Pod2["Pod 2: CLI"] + Pod3["Pod 3: CLI"] + PVC["PersistentVolumeClaim
(shared session state)"] + end + + App["Your App Pods"] --> Svc + Svc --> Pod1 + Svc --> Pod2 + Svc --> Pod3 + + Pod1 --> PVC + Pod2 --> PVC + Pod3 --> PVC + + style K8s fill:#0d1117,stroke:#58a6ff,color:#c9d1d9 +``` + +### Azure Container Instances + +```yaml +containers: + - name: copilot-cli + image: ghcr.io/github/copilot-cli:latest + command: ["copilot", "--headless", "--port", "4321"] + volumeMounts: + - name: session-storage + mountPath: /root/.copilot/session-state + +volumes: + - name: session-storage + azureFile: + shareName: copilot-sessions + storageAccountName: myaccount +``` + +## Production Checklist + +```mermaid +flowchart TB + subgraph Checklist["Production Readiness"] + direction TB + A["✅ Session cleanup
cron / TTL"] + B["✅ Health checks
ping endpoint"] + C["✅ Persistent storage
for session state"] + D["✅ Secret management
for tokens/keys"] + E["✅ Monitoring
active sessions, latency"] + F["✅ Session locking
if shared sessions"] + G["✅ Graceful shutdown
drain active sessions"] + end + + style Checklist fill:#0d1117,stroke:#3fb950,color:#c9d1d9 +``` + +| Concern | Recommendation | +|---------|---------------| +| **Session cleanup** | Run periodic cleanup to delete sessions older than your TTL | +| **Health checks** | Ping the CLI server periodically; restart if unresponsive | +| **Storage** | Mount persistent volumes for `~/.copilot/session-state/` | +| **Secrets** | Use your platform's secret manager (Vault, K8s Secrets, etc.) | +| **Monitoring** | Track active session count, response latency, error rates | +| **Locking** | Use Redis or similar for shared session access | +| **Shutdown** | Drain active sessions before stopping CLI servers | + +## Limitations + +| Limitation | Details | +|------------|---------| +| **No built-in session locking** | Implement application-level locking for concurrent access | +| **No built-in load balancing** | Use external LB or service mesh | +| **Session state is file-based** | Requires shared filesystem for multi-server setups | +| **30-minute idle timeout** | Sessions without activity are auto-cleaned by the CLI | +| **CLI is single-process** | Scale by adding more CLI server instances, not threads | + +## Next Steps + +- **[Session Persistence](../session-persistence.md)** — Deep dive on resumable sessions +- **[Backend Services](./backend-services.md)** — Core server-side setup +- **[GitHub OAuth](./github-oauth.md)** — Multi-user authentication +- **[BYOK](./byok.md)** — Use your own model provider diff --git a/dotnet/GitHub.Copilot.SDK.slnx b/dotnet/GitHub.Copilot.SDK.slnx index 1b82fb552..96fc3f0dc 100644 --- a/dotnet/GitHub.Copilot.SDK.slnx +++ b/dotnet/GitHub.Copilot.SDK.slnx @@ -10,4 +10,7 @@ + + + diff --git a/dotnet/README.md b/dotnet/README.md index d78e7a6b4..bda10059d 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -10,6 +10,15 @@ SDK for programmatic control of GitHub Copilot CLI. dotnet add package GitHub.Copilot.SDK ``` +## Run the Sample + +Try the interactive chat sample (from the repo root): + +```bash +cd dotnet/samples +dotnet run +``` + ## Quick Start ```csharp diff --git a/dotnet/samples/Chat.cs b/dotnet/samples/Chat.cs new file mode 100644 index 000000000..f4f12cfa2 --- /dev/null +++ b/dotnet/samples/Chat.cs @@ -0,0 +1,35 @@ +using GitHub.Copilot.SDK; + +await using var client = new CopilotClient(); +await using var session = await client.CreateSessionAsync(new SessionConfig +{ + OnPermissionRequest = PermissionHandler.ApproveAll +}); + +using var _ = session.On(evt => +{ + Console.ForegroundColor = ConsoleColor.Blue; + switch (evt) + { + case AssistantReasoningEvent reasoning: + Console.WriteLine($"[reasoning: {reasoning.Data.Content}]"); + break; + case ToolExecutionStartEvent tool: + Console.WriteLine($"[tool: {tool.Data.ToolName}]"); + break; + } + Console.ResetColor(); +}); + +Console.WriteLine("Chat with Copilot (Ctrl+C to exit)\n"); + +while (true) +{ + Console.Write("You: "); + var input = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(input)) continue; + Console.WriteLine(); + + var reply = await session.SendAndWaitAsync(new MessageOptions { Prompt = input }); + Console.WriteLine($"\nAssistant: {reply?.Data.Content}\n"); +} diff --git a/dotnet/samples/Chat.csproj b/dotnet/samples/Chat.csproj new file mode 100644 index 000000000..4121ceaef --- /dev/null +++ b/dotnet/samples/Chat.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 74f1c66f2..8c70a4a2b 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -11,9 +11,11 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Net.Sockets; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using GitHub.Copilot.SDK.Rpc; namespace GitHub.Copilot.SDK; @@ -63,6 +65,19 @@ public partial class CopilotClient : IDisposable, IAsyncDisposable private readonly List> _lifecycleHandlers = new(); private readonly Dictionary>> _typedLifecycleHandlers = new(); private readonly object _lifecycleHandlersLock = new(); + private ServerRpc? _rpc; + + /// + /// Gets the typed RPC client for server-scoped methods (no session required). + /// + /// + /// The client must be started before accessing this property. Use or set to true. + /// + /// Thrown if the client has been disposed. + /// Thrown if the client is not started. + public ServerRpc Rpc => _disposed + ? throw new ObjectDisposedException(nameof(CopilotClient)) + : _rpc ?? throw new InvalidOperationException("Client is not started. Call StartAsync first."); /// /// Creates a new instance of . @@ -90,9 +105,15 @@ public CopilotClient(CopilotClientOptions? options = null) _options = options ?? new(); // Validate mutually exclusive options - if (!string.IsNullOrEmpty(_options.CliUrl) && (_options.UseStdio || _options.CliPath != null)) + if (!string.IsNullOrEmpty(_options.CliUrl) && _options.CliPath != null) + { + throw new ArgumentException("CliUrl is mutually exclusive with CliPath"); + } + + // When CliUrl is provided, disable UseStdio (we connect to an external server, not spawn one) + if (!string.IsNullOrEmpty(_options.CliUrl)) { - throw new ArgumentException("CliUrl is mutually exclusive with UseStdio and CliPath"); + _options.UseStdio = false; } // Validate auth options with external server @@ -169,13 +190,13 @@ async Task StartCoreAsync(CancellationToken ct) if (_optionsHost is not null && _optionsPort is not null) { // External server (TCP) - result = ConnectToServerAsync(null, _optionsHost, _optionsPort, ct); + result = ConnectToServerAsync(null, _optionsHost, _optionsPort, null, ct); } else { // Child process (stdio or TCP) - var (cliProcess, portOrNull) = await StartCliServerAsync(_options, _logger, ct); - result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, ct); + var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _logger, ct); + result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct); } var connection = await result; @@ -289,7 +310,8 @@ private async Task CleanupConnectionAsync(List? errors) try { ctx.Rpc.Dispose(); } catch (Exception ex) { errors?.Add(ex); } - // Clear models cache + // Clear RPC and models cache + _rpc = null; _modelsCache = null; if (ctx.NetworkStream is not null) @@ -355,18 +377,20 @@ public async Task CreateSessionAsync(SessionConfig? config = nul var request = new CreateSessionRequest( config?.Model, config?.SessionId, + config?.ClientName, config?.ReasoningEffort, config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), config?.SystemMessage, config?.AvailableTools, config?.ExcludedTools, config?.Provider, - config?.OnPermissionRequest != null ? true : null, + (bool?)true, config?.OnUserInputRequest != null ? true : null, hasHooks ? true : null, config?.WorkingDirectory, config?.Streaming == true ? true : null, config?.McpServers, + "direct", config?.CustomAgents, config?.ConfigDir, config?.SkillDirectories, @@ -437,6 +461,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes var request = new ResumeSessionRequest( sessionId, + config?.ClientName, config?.Model, config?.ReasoningEffort, config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), @@ -444,7 +469,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config?.AvailableTools, config?.ExcludedTools, config?.Provider, - config?.OnPermissionRequest != null ? true : null, + (bool?)true, config?.OnUserInputRequest != null ? true : null, hasHooks ? true : null, config?.WorkingDirectory, @@ -452,6 +477,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config?.DisableResume == true ? true : null, config?.Streaming == true ? true : null, config?.McpServers, + "direct", config?.CustomAgents, config?.SkillDirectories, config?.DisabledSkills, @@ -652,6 +678,7 @@ public async Task DeleteSessionAsync(string sessionId, CancellationToken cancell /// /// Lists all sessions known to the Copilot server. /// + /// Optional filter to narrow down the session list by cwd, git root, repository, or branch. /// A that can be used to cancel the operation. /// A task that resolves with a list of for all available sessions. /// Thrown when the client is not connected. @@ -664,12 +691,12 @@ public async Task DeleteSessionAsync(string sessionId, CancellationToken cancell /// } /// /// - public async Task> ListSessionsAsync(CancellationToken cancellationToken = default) + public async Task> ListSessionsAsync(SessionListFilter? filter = null, CancellationToken cancellationToken = default) { var connection = await EnsureConnectedAsync(cancellationToken); var response = await InvokeRpcAsync( - connection.Rpc, "session.list", [], cancellationToken); + connection.Rpc, "session.list", [new ListSessionsRequest(filter)], cancellationToken); return response.Sessions; } @@ -826,11 +853,33 @@ private void DispatchLifecycleEvent(SessionLifecycleEvent evt) } internal static async Task InvokeRpcAsync(JsonRpc rpc, string method, object?[]? args, CancellationToken cancellationToken) + { + return await InvokeRpcAsync(rpc, method, args, null, cancellationToken); + } + + internal static async Task InvokeRpcAsync(JsonRpc rpc, string method, object?[]? args, StringBuilder? stderrBuffer, CancellationToken cancellationToken) { try { return await rpc.InvokeWithCancellationAsync(method, args, cancellationToken); } + catch (StreamJsonRpc.ConnectionLostException ex) + { + string? stderrOutput = null; + if (stderrBuffer is not null) + { + lock (stderrBuffer) + { + stderrOutput = stderrBuffer.ToString().Trim(); + } + } + + if (!string.IsNullOrEmpty(stderrOutput)) + { + throw new IOException($"CLI process exited unexpectedly.\nstderr: {stderrOutput}", ex); + } + throw new IOException($"Communication error with Copilot CLI: {ex.Message}", ex); + } catch (StreamJsonRpc.RemoteRpcException ex) { throw new IOException($"Communication error with Copilot CLI: {ex.Message}", ex); @@ -852,7 +901,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio { var expectedVersion = SdkProtocolVersion.GetVersion(); var pingResponse = await InvokeRpcAsync( - connection.Rpc, "ping", [new PingRequest()], cancellationToken); + connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken); if (!pingResponse.ProtocolVersion.HasValue) { @@ -871,7 +920,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio } } - private static async Task<(Process Process, int? DetectedLocalhostTcpPort)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken) + private static async Task<(Process Process, int? DetectedLocalhostTcpPort, StringBuilder StderrBuffer)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken) { // Use explicit path or bundled CLI - no PATH fallback var cliPath = options.CliPath ?? GetBundledCliPath(out var searchedPath) @@ -941,7 +990,8 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio var cliProcess = new Process { StartInfo = startInfo }; cliProcess.Start(); - // Forward stderr to logger + // Capture stderr for error messages and forward to logger + var stderrBuffer = new StringBuilder(); _ = Task.Run(async () => { while (cliProcess != null && !cliProcess.HasExited) @@ -949,6 +999,10 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio var line = await cliProcess.StandardError.ReadLineAsync(cancellationToken); if (line != null) { + lock (stderrBuffer) + { + stderrBuffer.AppendLine(line); + } logger.LogDebug("[CLI] {Line}", line); } } @@ -975,17 +1029,38 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio } } - return (cliProcess, detectedLocalhostTcpPort); + return (cliProcess, detectedLocalhostTcpPort, stderrBuffer); } private static string? GetBundledCliPath(out string searchedPath) { var binaryName = OperatingSystem.IsWindows() ? "copilot.exe" : "copilot"; - var rid = Path.GetFileName(System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier); + // Always use portable RID (e.g., linux-x64) to match the build-time placement, + // since distro-specific RIDs (e.g., ubuntu.24.04-x64) are normalized at build time. + var rid = GetPortableRid() + ?? Path.GetFileName(System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier); searchedPath = Path.Combine(AppContext.BaseDirectory, "runtimes", rid, "native", binaryName); return File.Exists(searchedPath) ? searchedPath : null; } + private static string? GetPortableRid() + { + string os; + if (OperatingSystem.IsWindows()) os = "win"; + else if (OperatingSystem.IsLinux()) os = "linux"; + else if (OperatingSystem.IsMacOS()) os = "osx"; + else return null; + + var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch + { + System.Runtime.InteropServices.Architecture.X64 => "x64", + System.Runtime.InteropServices.Architecture.Arm64 => "arm64", + _ => null, + }; + + return arch != null ? $"{os}-{arch}" : null; + } + private static (string FileName, IEnumerable Args) ResolveCliCommand(string cliPath, IEnumerable args) { var isJsFile = cliPath.EndsWith(".js", StringComparison.OrdinalIgnoreCase); @@ -998,7 +1073,7 @@ private static (string FileName, IEnumerable Args) ResolveCliCommand(str return (cliPath, args); } - private async Task ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, CancellationToken cancellationToken) + private async Task ConnectToServerAsync(Process? cliProcess, string? tcpHost, int? tcpPort, StringBuilder? stderrBuffer, CancellationToken cancellationToken) { Stream inputStream, outputStream; TcpClient? tcpClient = null; @@ -1040,7 +1115,10 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest); rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); rpc.StartListening(); - return new Connection(rpc, cliProcess, tcpClient, networkStream); + + _rpc = new ServerRpc(rpc); + + return new Connection(rpc, cliProcess, tcpClient, networkStream, stderrBuffer); } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")] @@ -1062,6 +1140,7 @@ private static JsonSerializerOptions CreateSerializerOptions() options.TypeInfoResolverChain.Add(TypesJsonContext.Default); options.TypeInfoResolverChain.Add(CopilotSession.SessionJsonContext.Default); options.TypeInfoResolverChain.Add(SessionEventsJsonContext.Default); + options.TypeInfoResolverChain.Add(SDK.Rpc.RpcJsonContext.Default); options.MakeReadOnly(); @@ -1280,12 +1359,14 @@ private class Connection( JsonRpc rpc, Process? cliProcess, // Set if we created the child process TcpClient? tcpClient, // Set if using TCP - NetworkStream? networkStream) // Set if using TCP + NetworkStream? networkStream, // Set if using TCP + StringBuilder? stderrBuffer = null) // Captures stderr for error messages { public Process? CliProcess => cliProcess; public TcpClient? TcpClient => tcpClient; public JsonRpc Rpc => rpc; public NetworkStream? NetworkStream => networkStream; + public StringBuilder? StderrBuffer => stderrBuffer; } private static class ProcessArgumentEscaper @@ -1302,6 +1383,7 @@ public static string Escape(string arg) internal record CreateSessionRequest( string? Model, string? SessionId, + string? ClientName, string? ReasoningEffort, List? Tools, SystemMessageConfig? SystemMessage, @@ -1314,6 +1396,7 @@ internal record CreateSessionRequest( string? WorkingDirectory, bool? Streaming, Dictionary? McpServers, + string? EnvValueMode, List? CustomAgents, string? ConfigDir, List? SkillDirectories, @@ -1335,6 +1418,7 @@ internal record CreateSessionResponse( internal record ResumeSessionRequest( string SessionId, + string? ClientName, string? Model, string? ReasoningEffort, List? Tools, @@ -1350,6 +1434,7 @@ internal record ResumeSessionRequest( bool? DisableResume, bool? Streaming, Dictionary? McpServers, + string? EnvValueMode, List? CustomAgents, List? SkillDirectories, List? DisabledSkills, @@ -1369,6 +1454,9 @@ internal record DeleteSessionResponse( bool Success, string? Error); + internal record ListSessionsRequest( + SessionListFilter? Filter); + internal record ListSessionsResponse( List Sessions); @@ -1438,6 +1526,7 @@ public override void WriteLine(string? message) => [JsonSerializable(typeof(DeleteSessionResponse))] [JsonSerializable(typeof(GetLastSessionIdResponse))] [JsonSerializable(typeof(HooksInvokeResponse))] + [JsonSerializable(typeof(ListSessionsRequest))] [JsonSerializable(typeof(ListSessionsResponse))] [JsonSerializable(typeof(PermissionRequestResponse))] [JsonSerializable(typeof(PermissionRequestResult))] diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs new file mode 100644 index 000000000..ac010ed86 --- /dev/null +++ b/dotnet/src/Generated/Rpc.cs @@ -0,0 +1,648 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +using System.Text.Json; +using System.Text.Json.Serialization; +using StreamJsonRpc; + +namespace GitHub.Copilot.SDK.Rpc; + +public class PingResult +{ + /// Echoed message (or default greeting) + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + /// Server timestamp in milliseconds + [JsonPropertyName("timestamp")] + public double Timestamp { get; set; } + + /// Server protocol version number + [JsonPropertyName("protocolVersion")] + public double ProtocolVersion { get; set; } +} + +internal class PingRequest +{ + [JsonPropertyName("message")] + public string? Message { get; set; } +} + +public class ModelCapabilitiesSupports +{ + [JsonPropertyName("vision")] + public bool Vision { get; set; } + + /// Whether this model supports reasoning effort configuration + [JsonPropertyName("reasoningEffort")] + public bool ReasoningEffort { get; set; } +} + +public class ModelCapabilitiesLimits +{ + [JsonPropertyName("max_prompt_tokens")] + public double? MaxPromptTokens { get; set; } + + [JsonPropertyName("max_output_tokens")] + public double? MaxOutputTokens { get; set; } + + [JsonPropertyName("max_context_window_tokens")] + public double MaxContextWindowTokens { get; set; } +} + +/// Model capabilities and limits +public class ModelCapabilities +{ + [JsonPropertyName("supports")] + public ModelCapabilitiesSupports Supports { get; set; } = new(); + + [JsonPropertyName("limits")] + public ModelCapabilitiesLimits Limits { get; set; } = new(); +} + +/// Policy state (if applicable) +public class ModelPolicy +{ + [JsonPropertyName("state")] + public string State { get; set; } = string.Empty; + + [JsonPropertyName("terms")] + public string Terms { get; set; } = string.Empty; +} + +/// Billing information +public class ModelBilling +{ + [JsonPropertyName("multiplier")] + public double Multiplier { get; set; } +} + +public class Model +{ + /// Model identifier (e.g., "claude-sonnet-4.5") + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// Display name + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Model capabilities and limits + [JsonPropertyName("capabilities")] + public ModelCapabilities Capabilities { get; set; } = new(); + + /// Policy state (if applicable) + [JsonPropertyName("policy")] + public ModelPolicy? Policy { get; set; } + + /// Billing information + [JsonPropertyName("billing")] + public ModelBilling? Billing { get; set; } + + /// Supported reasoning effort levels (only present if model supports reasoning effort) + [JsonPropertyName("supportedReasoningEfforts")] + public List? SupportedReasoningEfforts { get; set; } + + /// Default reasoning effort level (only present if model supports reasoning effort) + [JsonPropertyName("defaultReasoningEffort")] + public string? DefaultReasoningEffort { get; set; } +} + +public class ModelsListResult +{ + /// List of available models with full metadata + [JsonPropertyName("models")] + public List Models { get; set; } = new(); +} + +public class Tool +{ + /// Tool identifier (e.g., "bash", "grep", "str_replace_editor") + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Optional namespaced name for declarative filtering (e.g., "playwright/navigate" for MCP tools) + [JsonPropertyName("namespacedName")] + public string? NamespacedName { get; set; } + + /// Description of what the tool does + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// JSON Schema for the tool's input parameters + [JsonPropertyName("parameters")] + public Dictionary? Parameters { get; set; } + + /// Optional instructions for how to use this tool effectively + [JsonPropertyName("instructions")] + public string? Instructions { get; set; } +} + +public class ToolsListResult +{ + /// List of available built-in tools with metadata + [JsonPropertyName("tools")] + public List Tools { get; set; } = new(); +} + +internal class ListRequest +{ + [JsonPropertyName("model")] + public string? Model { get; set; } +} + +public class AccountGetQuotaResultQuotaSnapshotsValue +{ + /// Number of requests included in the entitlement + [JsonPropertyName("entitlementRequests")] + public double EntitlementRequests { get; set; } + + /// Number of requests used so far this period + [JsonPropertyName("usedRequests")] + public double UsedRequests { get; set; } + + /// Percentage of entitlement remaining + [JsonPropertyName("remainingPercentage")] + public double RemainingPercentage { get; set; } + + /// Number of overage requests made this period + [JsonPropertyName("overage")] + public double Overage { get; set; } + + /// Whether pay-per-request usage is allowed when quota is exhausted + [JsonPropertyName("overageAllowedWithExhaustedQuota")] + public bool OverageAllowedWithExhaustedQuota { get; set; } + + /// Date when the quota resets (ISO 8601) + [JsonPropertyName("resetDate")] + public string? ResetDate { get; set; } +} + +public class AccountGetQuotaResult +{ + /// Quota snapshots keyed by type (e.g., chat, completions, premium_interactions) + [JsonPropertyName("quotaSnapshots")] + public Dictionary QuotaSnapshots { get; set; } = new(); +} + +public class SessionModelGetCurrentResult +{ + [JsonPropertyName("modelId")] + public string? ModelId { get; set; } +} + +internal class GetCurrentRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +public class SessionModelSwitchToResult +{ + [JsonPropertyName("modelId")] + public string? ModelId { get; set; } +} + +internal class SwitchToRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("modelId")] + public string ModelId { get; set; } = string.Empty; +} + +public class SessionModeGetResult +{ + /// The current agent mode. + [JsonPropertyName("mode")] + public SessionModeGetResultMode Mode { get; set; } +} + +internal class GetRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +public class SessionModeSetResult +{ + /// The agent mode after switching. + [JsonPropertyName("mode")] + public SessionModeGetResultMode Mode { get; set; } +} + +internal class SetRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("mode")] + public SessionModeGetResultMode Mode { get; set; } +} + +public class SessionPlanReadResult +{ + /// Whether plan.md exists in the workspace + [JsonPropertyName("exists")] + public bool Exists { get; set; } + + /// The content of plan.md, or null if it does not exist + [JsonPropertyName("content")] + public string? Content { get; set; } +} + +internal class ReadRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +public class SessionPlanUpdateResult +{ +} + +internal class UpdateRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; +} + +public class SessionPlanDeleteResult +{ +} + +internal class DeleteRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +public class SessionWorkspaceListFilesResult +{ + /// Relative file paths in the workspace files directory + [JsonPropertyName("files")] + public List Files { get; set; } = new(); +} + +internal class ListFilesRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +public class SessionWorkspaceReadFileResult +{ + /// File content as a UTF-8 string + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; +} + +internal class ReadFileRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; +} + +public class SessionWorkspaceCreateFileResult +{ +} + +internal class CreateFileRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; +} + +public class SessionFleetStartResult +{ + /// Whether fleet mode was successfully activated + [JsonPropertyName("started")] + public bool Started { get; set; } +} + +internal class StartRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("prompt")] + public string? Prompt { get; set; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionModeGetResultMode +{ + [JsonStringEnumMemberName("interactive")] + Interactive, + [JsonStringEnumMemberName("plan")] + Plan, + [JsonStringEnumMemberName("autopilot")] + Autopilot, +} + + +/// Typed server-scoped RPC methods (no session required). +public class ServerRpc +{ + private readonly JsonRpc _rpc; + + internal ServerRpc(JsonRpc rpc) + { + _rpc = rpc; + Models = new ModelsApi(rpc); + Tools = new ToolsApi(rpc); + Account = new AccountApi(rpc); + } + + /// Calls "ping". + public async Task PingAsync(string? message = null, CancellationToken cancellationToken = default) + { + var request = new PingRequest { Message = message }; + return await CopilotClient.InvokeRpcAsync(_rpc, "ping", [request], cancellationToken); + } + + /// Models APIs. + public ModelsApi Models { get; } + + /// Tools APIs. + public ToolsApi Tools { get; } + + /// Account APIs. + public AccountApi Account { get; } +} + +/// Server-scoped Models APIs. +public class ModelsApi +{ + private readonly JsonRpc _rpc; + + internal ModelsApi(JsonRpc rpc) + { + _rpc = rpc; + } + + /// Calls "models.list". + public async Task ListAsync(CancellationToken cancellationToken = default) + { + return await CopilotClient.InvokeRpcAsync(_rpc, "models.list", [], cancellationToken); + } +} + +/// Server-scoped Tools APIs. +public class ToolsApi +{ + private readonly JsonRpc _rpc; + + internal ToolsApi(JsonRpc rpc) + { + _rpc = rpc; + } + + /// Calls "tools.list". + public async Task ListAsync(string? model = null, CancellationToken cancellationToken = default) + { + var request = new ListRequest { Model = model }; + return await CopilotClient.InvokeRpcAsync(_rpc, "tools.list", [request], cancellationToken); + } +} + +/// Server-scoped Account APIs. +public class AccountApi +{ + private readonly JsonRpc _rpc; + + internal AccountApi(JsonRpc rpc) + { + _rpc = rpc; + } + + /// Calls "account.getQuota". + public async Task GetQuotaAsync(CancellationToken cancellationToken = default) + { + return await CopilotClient.InvokeRpcAsync(_rpc, "account.getQuota", [], cancellationToken); + } +} + +/// Typed session-scoped RPC methods. +public class SessionRpc +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal SessionRpc(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + Model = new ModelApi(rpc, sessionId); + Mode = new ModeApi(rpc, sessionId); + Plan = new PlanApi(rpc, sessionId); + Workspace = new WorkspaceApi(rpc, sessionId); + Fleet = new FleetApi(rpc, sessionId); + } + + public ModelApi Model { get; } + + public ModeApi Mode { get; } + + public PlanApi Plan { get; } + + public WorkspaceApi Workspace { get; } + + public FleetApi Fleet { get; } +} + +public class ModelApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal ModelApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.model.getCurrent". + public async Task GetCurrentAsync(CancellationToken cancellationToken = default) + { + var request = new GetCurrentRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.model.getCurrent", [request], cancellationToken); + } + + /// Calls "session.model.switchTo". + public async Task SwitchToAsync(string modelId, CancellationToken cancellationToken = default) + { + var request = new SwitchToRequest { SessionId = _sessionId, ModelId = modelId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.model.switchTo", [request], cancellationToken); + } +} + +public class ModeApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal ModeApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.mode.get". + public async Task GetAsync(CancellationToken cancellationToken = default) + { + var request = new GetRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.mode.get", [request], cancellationToken); + } + + /// Calls "session.mode.set". + public async Task SetAsync(SessionModeGetResultMode mode, CancellationToken cancellationToken = default) + { + var request = new SetRequest { SessionId = _sessionId, Mode = mode }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.mode.set", [request], cancellationToken); + } +} + +public class PlanApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal PlanApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.plan.read". + public async Task ReadAsync(CancellationToken cancellationToken = default) + { + var request = new ReadRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.plan.read", [request], cancellationToken); + } + + /// Calls "session.plan.update". + public async Task UpdateAsync(string content, CancellationToken cancellationToken = default) + { + var request = new UpdateRequest { SessionId = _sessionId, Content = content }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.plan.update", [request], cancellationToken); + } + + /// Calls "session.plan.delete". + public async Task DeleteAsync(CancellationToken cancellationToken = default) + { + var request = new DeleteRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.plan.delete", [request], cancellationToken); + } +} + +public class WorkspaceApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal WorkspaceApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.workspace.listFiles". + public async Task ListFilesAsync(CancellationToken cancellationToken = default) + { + var request = new ListFilesRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.workspace.listFiles", [request], cancellationToken); + } + + /// Calls "session.workspace.readFile". + public async Task ReadFileAsync(string path, CancellationToken cancellationToken = default) + { + var request = new ReadFileRequest { SessionId = _sessionId, Path = path }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.workspace.readFile", [request], cancellationToken); + } + + /// Calls "session.workspace.createFile". + public async Task CreateFileAsync(string path, string content, CancellationToken cancellationToken = default) + { + var request = new CreateFileRequest { SessionId = _sessionId, Path = path, Content = content }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.workspace.createFile", [request], cancellationToken); + } +} + +public class FleetApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal FleetApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.fleet.start". + public async Task StartAsync(string? prompt, CancellationToken cancellationToken = default) + { + var request = new StartRequest { SessionId = _sessionId, Prompt = prompt }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.fleet.start", [request], cancellationToken); + } +} + +[JsonSourceGenerationOptions( + JsonSerializerDefaults.Web, + AllowOutOfOrderMetadataProperties = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(AccountGetQuotaResult))] +[JsonSerializable(typeof(AccountGetQuotaResultQuotaSnapshotsValue))] +[JsonSerializable(typeof(CreateFileRequest))] +[JsonSerializable(typeof(DeleteRequest))] +[JsonSerializable(typeof(GetCurrentRequest))] +[JsonSerializable(typeof(GetRequest))] +[JsonSerializable(typeof(ListFilesRequest))] +[JsonSerializable(typeof(ListRequest))] +[JsonSerializable(typeof(Model))] +[JsonSerializable(typeof(ModelBilling))] +[JsonSerializable(typeof(ModelCapabilities))] +[JsonSerializable(typeof(ModelCapabilitiesLimits))] +[JsonSerializable(typeof(ModelCapabilitiesSupports))] +[JsonSerializable(typeof(ModelPolicy))] +[JsonSerializable(typeof(ModelsListResult))] +[JsonSerializable(typeof(PingRequest))] +[JsonSerializable(typeof(PingResult))] +[JsonSerializable(typeof(ReadFileRequest))] +[JsonSerializable(typeof(ReadRequest))] +[JsonSerializable(typeof(SessionFleetStartResult))] +[JsonSerializable(typeof(SessionModeGetResult))] +[JsonSerializable(typeof(SessionModeSetResult))] +[JsonSerializable(typeof(SessionModelGetCurrentResult))] +[JsonSerializable(typeof(SessionModelSwitchToResult))] +[JsonSerializable(typeof(SessionPlanDeleteResult))] +[JsonSerializable(typeof(SessionPlanReadResult))] +[JsonSerializable(typeof(SessionPlanUpdateResult))] +[JsonSerializable(typeof(SessionWorkspaceCreateFileResult))] +[JsonSerializable(typeof(SessionWorkspaceListFilesResult))] +[JsonSerializable(typeof(SessionWorkspaceReadFileResult))] +[JsonSerializable(typeof(SetRequest))] +[JsonSerializable(typeof(StartRequest))] +[JsonSerializable(typeof(SwitchToRequest))] +[JsonSerializable(typeof(Tool))] +[JsonSerializable(typeof(ToolsListResult))] +[JsonSerializable(typeof(UpdateRequest))] +internal partial class RpcJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 022588396..c2549803a 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -3,14 +3,7 @@ *--------------------------------------------------------------------------------------------*/ // AUTO-GENERATED FILE - DO NOT EDIT -// -// Generated from: @github/copilot/session-events.schema.json -// Generated by: scripts/generate-session-types.ts -// Generated at: 2026-02-06T20:38:23.832Z -// -// To update these types: -// 1. Update the schema in copilot-agent-runtime -// 2. Run: npm run generate:session-types +// Generated from: session-events.schema.json using System.Text.Json; using System.Text.Json.Serialization; @@ -37,17 +30,23 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(PendingMessagesModifiedEvent), "pending_messages.modified")] [JsonDerivedType(typeof(SessionCompactionCompleteEvent), "session.compaction_complete")] [JsonDerivedType(typeof(SessionCompactionStartEvent), "session.compaction_start")] +[JsonDerivedType(typeof(SessionContextChangedEvent), "session.context_changed")] [JsonDerivedType(typeof(SessionErrorEvent), "session.error")] [JsonDerivedType(typeof(SessionHandoffEvent), "session.handoff")] [JsonDerivedType(typeof(SessionIdleEvent), "session.idle")] [JsonDerivedType(typeof(SessionInfoEvent), "session.info")] +[JsonDerivedType(typeof(SessionModeChangedEvent), "session.mode_changed")] [JsonDerivedType(typeof(SessionModelChangeEvent), "session.model_change")] +[JsonDerivedType(typeof(SessionPlanChangedEvent), "session.plan_changed")] [JsonDerivedType(typeof(SessionResumeEvent), "session.resume")] [JsonDerivedType(typeof(SessionShutdownEvent), "session.shutdown")] [JsonDerivedType(typeof(SessionSnapshotRewindEvent), "session.snapshot_rewind")] [JsonDerivedType(typeof(SessionStartEvent), "session.start")] +[JsonDerivedType(typeof(SessionTitleChangedEvent), "session.title_changed")] [JsonDerivedType(typeof(SessionTruncationEvent), "session.truncation")] [JsonDerivedType(typeof(SessionUsageInfoEvent), "session.usage_info")] +[JsonDerivedType(typeof(SessionWarningEvent), "session.warning")] +[JsonDerivedType(typeof(SessionWorkspaceFileChangedEvent), "session.workspace_file_changed")] [JsonDerivedType(typeof(SkillInvokedEvent), "skill.invoked")] [JsonDerivedType(typeof(SubagentCompletedEvent), "subagent.completed")] [JsonDerivedType(typeof(SubagentFailedEvent), "subagent.failed")] @@ -136,6 +135,18 @@ public partial class SessionIdleEvent : SessionEvent public required SessionIdleData Data { get; set; } } +/// +/// Event: session.title_changed +/// +public partial class SessionTitleChangedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "session.title_changed"; + + [JsonPropertyName("data")] + public required SessionTitleChangedData Data { get; set; } +} + /// /// Event: session.info /// @@ -148,6 +159,18 @@ public partial class SessionInfoEvent : SessionEvent public required SessionInfoData Data { get; set; } } +/// +/// Event: session.warning +/// +public partial class SessionWarningEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "session.warning"; + + [JsonPropertyName("data")] + public required SessionWarningData Data { get; set; } +} + /// /// Event: session.model_change /// @@ -160,6 +183,42 @@ public partial class SessionModelChangeEvent : SessionEvent public required SessionModelChangeData Data { get; set; } } +/// +/// Event: session.mode_changed +/// +public partial class SessionModeChangedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "session.mode_changed"; + + [JsonPropertyName("data")] + public required SessionModeChangedData Data { get; set; } +} + +/// +/// Event: session.plan_changed +/// +public partial class SessionPlanChangedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "session.plan_changed"; + + [JsonPropertyName("data")] + public required SessionPlanChangedData Data { get; set; } +} + +/// +/// Event: session.workspace_file_changed +/// +public partial class SessionWorkspaceFileChangedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "session.workspace_file_changed"; + + [JsonPropertyName("data")] + public required SessionWorkspaceFileChangedData Data { get; set; } +} + /// /// Event: session.handoff /// @@ -208,6 +267,18 @@ public partial class SessionShutdownEvent : SessionEvent public required SessionShutdownData Data { get; set; } } +/// +/// Event: session.context_changed +/// +public partial class SessionContextChangedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "session.context_changed"; + + [JsonPropertyName("data")] + public required SessionContextChangedData Data { get; set; } +} + /// /// Event: session.usage_info /// @@ -596,6 +667,12 @@ public partial class SessionIdleData { } +public partial class SessionTitleChangedData +{ + [JsonPropertyName("title")] + public required string Title { get; set; } +} + public partial class SessionInfoData { [JsonPropertyName("infoType")] @@ -605,6 +682,15 @@ public partial class SessionInfoData public required string Message { get; set; } } +public partial class SessionWarningData +{ + [JsonPropertyName("warningType")] + public required string WarningType { get; set; } + + [JsonPropertyName("message")] + public required string Message { get; set; } +} + public partial class SessionModelChangeData { [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -615,6 +701,30 @@ public partial class SessionModelChangeData public required string NewModel { get; set; } } +public partial class SessionModeChangedData +{ + [JsonPropertyName("previousMode")] + public required string PreviousMode { get; set; } + + [JsonPropertyName("newMode")] + public required string NewMode { get; set; } +} + +public partial class SessionPlanChangedData +{ + [JsonPropertyName("operation")] + public required SessionPlanChangedDataOperation Operation { get; set; } +} + +public partial class SessionWorkspaceFileChangedData +{ + [JsonPropertyName("path")] + public required string Path { get; set; } + + [JsonPropertyName("operation")] + public required SessionWorkspaceFileChangedDataOperation Operation { get; set; } +} + public partial class SessionHandoffData { [JsonPropertyName("handoffTime")] @@ -705,6 +815,24 @@ public partial class SessionShutdownData public string? CurrentModel { get; set; } } +public partial class SessionContextChangedData +{ + [JsonPropertyName("cwd")] + public required string Cwd { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("gitRoot")] + public string? GitRoot { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("repository")] + public string? Repository { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("branch")] + public string? Branch { get; set; } +} + public partial class SessionUsageInfoData { [JsonPropertyName("tokenLimit")] @@ -787,6 +915,10 @@ public partial class UserMessageData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("source")] public string? Source { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("agentMode")] + public UserMessageDataAgentMode? AgentMode { get; set; } } public partial class PendingMessagesModifiedData @@ -847,6 +979,10 @@ public partial class AssistantMessageData [JsonPropertyName("encryptedContent")] public string? EncryptedContent { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("phase")] + public string? Phase { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("parentToolCallId")] public string? ParentToolCallId { get; set; } @@ -1054,6 +1190,9 @@ public partial class SubagentCompletedData [JsonPropertyName("agentName")] public required string AgentName { get; set; } + + [JsonPropertyName("agentDisplayName")] + public required string AgentDisplayName { get; set; } } public partial class SubagentFailedData @@ -1064,6 +1203,9 @@ public partial class SubagentFailedData [JsonPropertyName("agentName")] public required string AgentName { get; set; } + [JsonPropertyName("agentDisplayName")] + public required string AgentDisplayName { get; set; } + [JsonPropertyName("error")] public required string Error { get; set; } } @@ -1203,6 +1345,15 @@ public partial class SessionCompactionCompleteDataCompactionTokensUsed public required double CachedInput { get; set; } } +public partial class UserMessageDataAttachmentsItemFileLineRange +{ + [JsonPropertyName("start")] + public required double Start { get; set; } + + [JsonPropertyName("end")] + public required double End { get; set; } +} + public partial class UserMessageDataAttachmentsItemFile : UserMessageDataAttachmentsItem { [JsonIgnore] @@ -1213,6 +1364,19 @@ public partial class UserMessageDataAttachmentsItemFile : UserMessageDataAttachm [JsonPropertyName("displayName")] public required string DisplayName { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("lineRange")] + public UserMessageDataAttachmentsItemFileLineRange? LineRange { get; set; } +} + +public partial class UserMessageDataAttachmentsItemDirectoryLineRange +{ + [JsonPropertyName("start")] + public required double Start { get; set; } + + [JsonPropertyName("end")] + public required double End { get; set; } } public partial class UserMessageDataAttachmentsItemDirectory : UserMessageDataAttachmentsItem @@ -1225,6 +1389,10 @@ public partial class UserMessageDataAttachmentsItemDirectory : UserMessageDataAt [JsonPropertyName("displayName")] public required string DisplayName { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("lineRange")] + public UserMessageDataAttachmentsItemDirectoryLineRange? LineRange { get; set; } } public partial class UserMessageDataAttachmentsItemSelectionSelectionStart @@ -1302,6 +1470,131 @@ public partial class AssistantMessageDataToolRequestsItem public AssistantMessageDataToolRequestsItemType? Type { get; set; } } +public partial class ToolExecutionCompleteDataResultContentsItemText : ToolExecutionCompleteDataResultContentsItem +{ + [JsonIgnore] + public override string Type => "text"; + + [JsonPropertyName("text")] + public required string Text { get; set; } +} + +public partial class ToolExecutionCompleteDataResultContentsItemTerminal : ToolExecutionCompleteDataResultContentsItem +{ + [JsonIgnore] + public override string Type => "terminal"; + + [JsonPropertyName("text")] + public required string Text { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("exitCode")] + public double? ExitCode { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("cwd")] + public string? Cwd { get; set; } +} + +public partial class ToolExecutionCompleteDataResultContentsItemImage : ToolExecutionCompleteDataResultContentsItem +{ + [JsonIgnore] + public override string Type => "image"; + + [JsonPropertyName("data")] + public required string Data { get; set; } + + [JsonPropertyName("mimeType")] + public required string MimeType { get; set; } +} + +public partial class ToolExecutionCompleteDataResultContentsItemAudio : ToolExecutionCompleteDataResultContentsItem +{ + [JsonIgnore] + public override string Type => "audio"; + + [JsonPropertyName("data")] + public required string Data { get; set; } + + [JsonPropertyName("mimeType")] + public required string MimeType { get; set; } +} + +public partial class ToolExecutionCompleteDataResultContentsItemResourceLinkIconsItem +{ + [JsonPropertyName("src")] + public required string Src { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("mimeType")] + public string? MimeType { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("sizes")] + public string[]? Sizes { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("theme")] + public ToolExecutionCompleteDataResultContentsItemResourceLinkIconsItemTheme? Theme { get; set; } +} + +public partial class ToolExecutionCompleteDataResultContentsItemResourceLink : ToolExecutionCompleteDataResultContentsItem +{ + [JsonIgnore] + public override string Type => "resource_link"; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("icons")] + public ToolExecutionCompleteDataResultContentsItemResourceLinkIconsItem[]? Icons { get; set; } + + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("uri")] + public required string Uri { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("mimeType")] + public string? MimeType { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("size")] + public double? Size { get; set; } +} + +public partial class ToolExecutionCompleteDataResultContentsItemResource : ToolExecutionCompleteDataResultContentsItem +{ + [JsonIgnore] + public override string Type => "resource"; + + [JsonPropertyName("resource")] + public required object Resource { get; set; } +} + +[JsonPolymorphic( + TypeDiscriminatorPropertyName = "type", + UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)] +[JsonDerivedType(typeof(ToolExecutionCompleteDataResultContentsItemText), "text")] +[JsonDerivedType(typeof(ToolExecutionCompleteDataResultContentsItemTerminal), "terminal")] +[JsonDerivedType(typeof(ToolExecutionCompleteDataResultContentsItemImage), "image")] +[JsonDerivedType(typeof(ToolExecutionCompleteDataResultContentsItemAudio), "audio")] +[JsonDerivedType(typeof(ToolExecutionCompleteDataResultContentsItemResourceLink), "resource_link")] +[JsonDerivedType(typeof(ToolExecutionCompleteDataResultContentsItemResource), "resource")] +public partial class ToolExecutionCompleteDataResultContentsItem +{ + [JsonPropertyName("type")] + public virtual string Type { get; set; } = string.Empty; +} + + public partial class ToolExecutionCompleteDataResult { [JsonPropertyName("content")] @@ -1310,6 +1603,10 @@ public partial class ToolExecutionCompleteDataResult [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("detailedContent")] public string? DetailedContent { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("contents")] + public ToolExecutionCompleteDataResultContentsItem[]? Contents { get; set; } } public partial class ToolExecutionCompleteDataError @@ -1343,6 +1640,26 @@ public partial class SystemMessageDataMetadata public Dictionary? Variables { get; set; } } +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionPlanChangedDataOperation +{ + [JsonStringEnumMemberName("create")] + Create, + [JsonStringEnumMemberName("update")] + Update, + [JsonStringEnumMemberName("delete")] + Delete, +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SessionWorkspaceFileChangedDataOperation +{ + [JsonStringEnumMemberName("create")] + Create, + [JsonStringEnumMemberName("update")] + Update, +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum SessionHandoffDataSourceType { @@ -1361,6 +1678,19 @@ public enum SessionShutdownDataShutdownType Error, } +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum UserMessageDataAgentMode +{ + [JsonStringEnumMemberName("interactive")] + Interactive, + [JsonStringEnumMemberName("plan")] + Plan, + [JsonStringEnumMemberName("autopilot")] + Autopilot, + [JsonStringEnumMemberName("shell")] + Shell, +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum AssistantMessageDataToolRequestsItemType { @@ -1370,6 +1700,15 @@ public enum AssistantMessageDataToolRequestsItemType Custom, } +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ToolExecutionCompleteDataResultContentsItemResourceLinkIconsItemTheme +{ + [JsonStringEnumMemberName("light")] + Light, + [JsonStringEnumMemberName("dark")] + Dark, +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum SystemMessageDataRole { @@ -1415,6 +1754,8 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(SessionCompactionCompleteEvent))] [JsonSerializable(typeof(SessionCompactionStartData))] [JsonSerializable(typeof(SessionCompactionStartEvent))] +[JsonSerializable(typeof(SessionContextChangedData))] +[JsonSerializable(typeof(SessionContextChangedEvent))] [JsonSerializable(typeof(SessionErrorData))] [JsonSerializable(typeof(SessionErrorEvent))] [JsonSerializable(typeof(SessionEvent))] @@ -1425,8 +1766,12 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(SessionIdleEvent))] [JsonSerializable(typeof(SessionInfoData))] [JsonSerializable(typeof(SessionInfoEvent))] +[JsonSerializable(typeof(SessionModeChangedData))] +[JsonSerializable(typeof(SessionModeChangedEvent))] [JsonSerializable(typeof(SessionModelChangeData))] [JsonSerializable(typeof(SessionModelChangeEvent))] +[JsonSerializable(typeof(SessionPlanChangedData))] +[JsonSerializable(typeof(SessionPlanChangedEvent))] [JsonSerializable(typeof(SessionResumeData))] [JsonSerializable(typeof(SessionResumeDataContext))] [JsonSerializable(typeof(SessionResumeEvent))] @@ -1438,10 +1783,16 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(SessionStartData))] [JsonSerializable(typeof(SessionStartDataContext))] [JsonSerializable(typeof(SessionStartEvent))] +[JsonSerializable(typeof(SessionTitleChangedData))] +[JsonSerializable(typeof(SessionTitleChangedEvent))] [JsonSerializable(typeof(SessionTruncationData))] [JsonSerializable(typeof(SessionTruncationEvent))] [JsonSerializable(typeof(SessionUsageInfoData))] [JsonSerializable(typeof(SessionUsageInfoEvent))] +[JsonSerializable(typeof(SessionWarningData))] +[JsonSerializable(typeof(SessionWarningEvent))] +[JsonSerializable(typeof(SessionWorkspaceFileChangedData))] +[JsonSerializable(typeof(SessionWorkspaceFileChangedEvent))] [JsonSerializable(typeof(SkillInvokedData))] [JsonSerializable(typeof(SkillInvokedEvent))] [JsonSerializable(typeof(SubagentCompletedData))] @@ -1458,6 +1809,14 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(ToolExecutionCompleteData))] [JsonSerializable(typeof(ToolExecutionCompleteDataError))] [JsonSerializable(typeof(ToolExecutionCompleteDataResult))] +[JsonSerializable(typeof(ToolExecutionCompleteDataResultContentsItem))] +[JsonSerializable(typeof(ToolExecutionCompleteDataResultContentsItemAudio))] +[JsonSerializable(typeof(ToolExecutionCompleteDataResultContentsItemImage))] +[JsonSerializable(typeof(ToolExecutionCompleteDataResultContentsItemResource))] +[JsonSerializable(typeof(ToolExecutionCompleteDataResultContentsItemResourceLink))] +[JsonSerializable(typeof(ToolExecutionCompleteDataResultContentsItemResourceLinkIconsItem))] +[JsonSerializable(typeof(ToolExecutionCompleteDataResultContentsItemTerminal))] +[JsonSerializable(typeof(ToolExecutionCompleteDataResultContentsItemText))] [JsonSerializable(typeof(ToolExecutionCompleteEvent))] [JsonSerializable(typeof(ToolExecutionPartialResultData))] [JsonSerializable(typeof(ToolExecutionPartialResultEvent))] @@ -1470,7 +1829,9 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(UserMessageData))] [JsonSerializable(typeof(UserMessageDataAttachmentsItem))] [JsonSerializable(typeof(UserMessageDataAttachmentsItemDirectory))] +[JsonSerializable(typeof(UserMessageDataAttachmentsItemDirectoryLineRange))] [JsonSerializable(typeof(UserMessageDataAttachmentsItemFile))] +[JsonSerializable(typeof(UserMessageDataAttachmentsItemFileLineRange))] [JsonSerializable(typeof(UserMessageDataAttachmentsItemSelection))] [JsonSerializable(typeof(UserMessageDataAttachmentsItemSelectionSelection))] [JsonSerializable(typeof(UserMessageDataAttachmentsItemSelectionSelectionEnd))] diff --git a/dotnet/src/PermissionHandlers.cs b/dotnet/src/PermissionHandlers.cs new file mode 100644 index 000000000..22e5bdb17 --- /dev/null +++ b/dotnet/src/PermissionHandlers.cs @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +namespace GitHub.Copilot.SDK; + +/// Provides pre-built implementations. +public static class PermissionHandler +{ + /// A that approves all permission requests. + public static PermissionRequestHandler ApproveAll { get; } = + (_, _) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }); +} diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index aa2d5b045..4feeb9f95 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using GitHub.Copilot.SDK.Rpc; namespace GitHub.Copilot.SDK; @@ -46,12 +47,14 @@ public partial class CopilotSession : IAsyncDisposable private readonly HashSet _eventHandlers = new(); private readonly Dictionary _toolHandlers = new(); private readonly JsonRpc _rpc; - private PermissionHandler? _permissionHandler; + private PermissionRequestHandler? _permissionHandler; private readonly SemaphoreSlim _permissionHandlerLock = new(1, 1); private UserInputHandler? _userInputHandler; private readonly SemaphoreSlim _userInputHandlerLock = new(1, 1); private SessionHooks? _hooks; private readonly SemaphoreSlim _hooksLock = new(1, 1); + private SessionRpc? _sessionRpc; + private int _isDisposed; /// /// Gets the unique identifier for this session. @@ -59,6 +62,11 @@ public partial class CopilotSession : IAsyncDisposable /// A string that uniquely identifies this session. public string SessionId { get; } + /// + /// Gets the typed RPC client for session-scoped methods. + /// + public SessionRpc Rpc => _sessionRpc ??= new SessionRpc(_rpc, SessionId); + /// /// Gets the path to the session workspace directory when infinite sessions are enabled. /// @@ -284,7 +292,7 @@ internal void RegisterTools(ICollection tools) /// When the assistant needs permission to perform certain actions (e.g., file operations), /// this handler is called to approve or deny the request. /// - internal void RegisterPermissionHandler(PermissionHandler handler) + internal void RegisterPermissionHandler(PermissionRequestHandler handler) { _permissionHandlerLock.Wait(); try @@ -305,7 +313,7 @@ internal void RegisterPermissionHandler(PermissionHandler handler) internal async Task HandlePermissionRequestAsync(JsonElement permissionRequestData) { await _permissionHandlerLock.WaitAsync(); - PermissionHandler? handler; + PermissionRequestHandler? handler; try { handler = _permissionHandler; @@ -553,8 +561,24 @@ await InvokeRpcAsync( /// public async ValueTask DisposeAsync() { - await InvokeRpcAsync( - "session.destroy", [new SessionDestroyRequest() { SessionId = SessionId }], CancellationToken.None); + if (Interlocked.Exchange(ref _isDisposed, 1) == 1) + { + return; + } + + try + { + await InvokeRpcAsync( + "session.destroy", [new SessionDestroyRequest() { SessionId = SessionId }], CancellationToken.None); + } + catch (ObjectDisposedException) + { + // Connection was already disposed (e.g., client.StopAsync() was called first) + } + catch (IOException) + { + // Connection is broken or closed + } _eventHandlers.Clear(); _toolHandlers.Clear(); diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 664b35d9e..acf03b4d2 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -24,6 +24,34 @@ public enum ConnectionState public class CopilotClientOptions { + /// + /// Initializes a new instance of the class. + /// + public CopilotClientOptions() { } + + /// + /// Initializes a new instance of the class + /// by copying the properties of the specified instance. + /// + protected CopilotClientOptions(CopilotClientOptions? other) + { + if (other is null) return; + + AutoRestart = other.AutoRestart; + AutoStart = other.AutoStart; + CliArgs = (string[]?)other.CliArgs?.Clone(); + CliPath = other.CliPath; + CliUrl = other.CliUrl; + Cwd = other.Cwd; + Environment = other.Environment; + GithubToken = other.GithubToken; + Logger = other.Logger; + LogLevel = other.LogLevel; + Port = other.Port; + UseLoggedInUser = other.UseLoggedInUser; + UseStdio = other.UseStdio; + } + /// /// Path to the Copilot CLI executable. If not specified, uses the bundled CLI from the SDK. /// @@ -53,6 +81,17 @@ public class CopilotClientOptions /// Default: true (but defaults to false when GithubToken is provided). /// public bool? UseLoggedInUser { get; set; } + + /// + /// Creates a shallow clone of this instance. + /// + /// + /// Mutable collection properties are copied into new collection instances so that modifications + /// to those collections on the clone do not affect the original. + /// Other reference-type properties (for example delegates and the logger) are not + /// deep-cloned; the original and the clone will share those objects. + /// + public virtual CopilotClientOptions Clone() => new(this); } public class ToolBinaryResult @@ -127,7 +166,7 @@ public class PermissionInvocation public string SessionId { get; set; } = string.Empty; } -public delegate Task PermissionHandler(PermissionRequest request, PermissionInvocation invocation); +public delegate Task PermissionRequestHandler(PermissionRequest request, PermissionInvocation invocation); // ============================================================================ // User Input Handler Types @@ -692,7 +731,51 @@ public class InfiniteSessionConfig public class SessionConfig { + /// + /// Initializes a new instance of the class. + /// + public SessionConfig() { } + + /// + /// Initializes a new instance of the class + /// by copying the properties of the specified instance. + /// + protected SessionConfig(SessionConfig? other) + { + if (other is null) return; + + AvailableTools = other.AvailableTools is not null ? [.. other.AvailableTools] : null; + ClientName = other.ClientName; + ConfigDir = other.ConfigDir; + CustomAgents = other.CustomAgents is not null ? [.. other.CustomAgents] : null; + DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null; + ExcludedTools = other.ExcludedTools is not null ? [.. other.ExcludedTools] : null; + Hooks = other.Hooks; + InfiniteSessions = other.InfiniteSessions; + McpServers = other.McpServers is not null + ? new Dictionary(other.McpServers, other.McpServers.Comparer) + : null; + Model = other.Model; + OnPermissionRequest = other.OnPermissionRequest; + OnUserInputRequest = other.OnUserInputRequest; + Provider = other.Provider; + ReasoningEffort = other.ReasoningEffort; + SessionId = other.SessionId; + SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null; + Streaming = other.Streaming; + SystemMessage = other.SystemMessage; + Tools = other.Tools is not null ? [.. other.Tools] : null; + WorkingDirectory = other.WorkingDirectory; + } + public string? SessionId { get; set; } + + /// + /// Client name to identify the application using the SDK. + /// Included in the User-Agent header for API requests. + /// + public string? ClientName { get; set; } + public string? Model { get; set; } /// @@ -718,7 +801,7 @@ public class SessionConfig /// Handler for permission requests from the server. /// When provided, the server will call this handler to request permission for operations. /// - public PermissionHandler? OnPermissionRequest { get; set; } + public PermissionRequestHandler? OnPermissionRequest { get; set; } /// /// Handler for user input requests from the agent. @@ -769,10 +852,65 @@ public class SessionConfig /// When enabled (default), sessions automatically manage context limits and persist state. /// public InfiniteSessionConfig? InfiniteSessions { get; set; } + + /// + /// Creates a shallow clone of this instance. + /// + /// + /// Mutable collection properties are copied into new collection instances so that modifications + /// to those collections on the clone do not affect the original. + /// Other reference-type properties (for example provider configuration, system messages, + /// hooks, infinite session configuration, and delegates) are not deep-cloned; the original + /// and the clone will share those nested objects, and changes to them may affect both. + /// + public virtual SessionConfig Clone() => new(this); } public class ResumeSessionConfig { + /// + /// Initializes a new instance of the class. + /// + public ResumeSessionConfig() { } + + /// + /// Initializes a new instance of the class + /// by copying the properties of the specified instance. + /// + protected ResumeSessionConfig(ResumeSessionConfig? other) + { + if (other is null) return; + + AvailableTools = other.AvailableTools is not null ? [.. other.AvailableTools] : null; + ClientName = other.ClientName; + ConfigDir = other.ConfigDir; + CustomAgents = other.CustomAgents is not null ? [.. other.CustomAgents] : null; + DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null; + DisableResume = other.DisableResume; + ExcludedTools = other.ExcludedTools is not null ? [.. other.ExcludedTools] : null; + Hooks = other.Hooks; + InfiniteSessions = other.InfiniteSessions; + McpServers = other.McpServers is not null + ? new Dictionary(other.McpServers, other.McpServers.Comparer) + : null; + Model = other.Model; + OnPermissionRequest = other.OnPermissionRequest; + OnUserInputRequest = other.OnUserInputRequest; + Provider = other.Provider; + ReasoningEffort = other.ReasoningEffort; + SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null; + Streaming = other.Streaming; + SystemMessage = other.SystemMessage; + Tools = other.Tools is not null ? [.. other.Tools] : null; + WorkingDirectory = other.WorkingDirectory; + } + + /// + /// Client name to identify the application using the SDK. + /// Included in the User-Agent header for API requests. + /// + public string? ClientName { get; set; } + /// /// Model to use for this session. Can change the model when resuming. /// @@ -809,7 +947,7 @@ public class ResumeSessionConfig /// Handler for permission requests from the server. /// When provided, the server will call this handler to request permission for operations. /// - public PermissionHandler? OnPermissionRequest { get; set; } + public PermissionRequestHandler? OnPermissionRequest { get; set; } /// /// Handler for user input requests from the agent. @@ -870,17 +1008,88 @@ public class ResumeSessionConfig /// Infinite session configuration for persistent workspaces and automatic compaction. /// public InfiniteSessionConfig? InfiniteSessions { get; set; } + + /// + /// Creates a shallow clone of this instance. + /// + /// + /// Mutable collection properties are copied into new collection instances so that modifications + /// to those collections on the clone do not affect the original. + /// Other reference-type properties (for example provider configuration, system messages, + /// hooks, infinite session configuration, and delegates) are not deep-cloned; the original + /// and the clone will share those nested objects, and changes to them may affect both. + /// + public virtual ResumeSessionConfig Clone() => new(this); } public class MessageOptions { + /// + /// Initializes a new instance of the class. + /// + public MessageOptions() { } + + /// + /// Initializes a new instance of the class + /// by copying the properties of the specified instance. + /// + protected MessageOptions(MessageOptions? other) + { + if (other is null) return; + + Attachments = other.Attachments is not null ? [.. other.Attachments] : null; + Mode = other.Mode; + Prompt = other.Prompt; + } + public string Prompt { get; set; } = string.Empty; public List? Attachments { get; set; } public string? Mode { get; set; } + + /// + /// Creates a shallow clone of this instance. + /// + /// + /// Mutable collection properties are copied into new collection instances so that modifications + /// to those collections on the clone do not affect the original. + /// Other reference-type properties (for example attachment items) are not deep-cloned; + /// the original and the clone will share those nested objects. + /// + public virtual MessageOptions Clone() => new(this); } public delegate void SessionEventHandler(SessionEvent sessionEvent); +/// +/// Working directory context for a session. +/// +public class SessionContext +{ + /// Working directory where the session was created. + public string Cwd { get; set; } = string.Empty; + /// Git repository root (if in a git repo). + public string? GitRoot { get; set; } + /// GitHub repository in "owner/repo" format. + public string? Repository { get; set; } + /// Current git branch. + public string? Branch { get; set; } +} + +/// +/// Filter options for listing sessions. +/// +public class SessionListFilter +{ + /// Filter by exact cwd match. + public string? Cwd { get; set; } + /// Filter by git root. + public string? GitRoot { get; set; } + /// Filter by repository (owner/repo format). + public string? Repository { get; set; } + /// Filter by branch. + public string? Branch { get; set; } +} + public class SessionMetadata { public string SessionId { get; set; } = string.Empty; @@ -888,6 +1097,8 @@ public class SessionMetadata public DateTime ModifiedTime { get; set; } public string? Summary { get; set; } public bool IsRemote { get; set; } + /// Working directory context (cwd, git info) from session creation. + public SessionContext? Context { get; set; } } internal class PingRequest @@ -1159,8 +1370,10 @@ public class SetForegroundSessionResponse [JsonSerializable(typeof(PingRequest))] [JsonSerializable(typeof(PingResponse))] [JsonSerializable(typeof(ProviderConfig))] +[JsonSerializable(typeof(SessionContext))] [JsonSerializable(typeof(SessionLifecycleEvent))] [JsonSerializable(typeof(SessionLifecycleEventMetadata))] +[JsonSerializable(typeof(SessionListFilter))] [JsonSerializable(typeof(SessionMetadata))] [JsonSerializable(typeof(SetForegroundSessionResponse))] [JsonSerializable(typeof(SystemMessageConfig))] diff --git a/dotnet/src/build/GitHub.Copilot.SDK.targets b/dotnet/src/build/GitHub.Copilot.SDK.targets index 20afd8156..b290e2b9c 100644 --- a/dotnet/src/build/GitHub.Copilot.SDK.targets +++ b/dotnet/src/build/GitHub.Copilot.SDK.targets @@ -3,17 +3,28 @@ - + - <_CopilotRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier) - <_CopilotRid Condition="'$(_CopilotRid)' == '' And $([MSBuild]::IsOSPlatform('Windows')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'X64'">win-x64 - <_CopilotRid Condition="'$(_CopilotRid)' == '' And $([MSBuild]::IsOSPlatform('Windows')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'Arm64'">win-arm64 - <_CopilotRid Condition="'$(_CopilotRid)' == '' And $([MSBuild]::IsOSPlatform('Linux')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'X64'">linux-x64 - <_CopilotRid Condition="'$(_CopilotRid)' == '' And $([MSBuild]::IsOSPlatform('Linux')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'Arm64'">linux-arm64 - <_CopilotRid Condition="'$(_CopilotRid)' == '' And $([MSBuild]::IsOSPlatform('OSX')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'X64'">osx-x64 - <_CopilotRid Condition="'$(_CopilotRid)' == '' And $([MSBuild]::IsOSPlatform('OSX')) And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'Arm64'">osx-arm64 + + <_CopilotOs Condition="'$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('win'))">win + <_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('osx'))">osx + <_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.StartsWith('maccatalyst'))">osx + <_CopilotOs Condition="'$(_CopilotOs)' == '' And '$(RuntimeIdentifier)' != ''">linux + + + <_CopilotArch Condition="'$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.EndsWith('-x64'))">x64 + <_CopilotArch Condition="'$(_CopilotArch)' == '' And '$(RuntimeIdentifier)' != '' And $(RuntimeIdentifier.EndsWith('-arm64'))">arm64 + + + <_CopilotRid Condition="'$(_CopilotOs)' != '' And '$(_CopilotArch)' != ''">$(_CopilotOs)-$(_CopilotArch) + <_CopilotRid Condition="'$(_CopilotRid)' == '' And '$(RuntimeIdentifier)' == ''">$(NETCoreSdkPortableRuntimeIdentifier) + + + + + <_CopilotPlatform Condition="'$(_CopilotRid)' == 'win-x64'">win32-x64 @@ -26,8 +37,26 @@ <_CopilotBinary Condition="'$(_CopilotBinary)' == ''">copilot - - + + + https://registry.npmjs.org + + + + + 600 + + + + @@ -35,7 +64,8 @@ <_CopilotCacheDir>$(IntermediateOutputPath)copilot-cli\$(CopilotCliVersion)\$(_CopilotPlatform) <_CopilotCliBinaryPath>$(_CopilotCacheDir)\$(_CopilotBinary) <_CopilotArchivePath>$(_CopilotCacheDir)\copilot.tgz - <_CopilotDownloadUrl>https://registry.npmjs.org/@github/copilot-$(_CopilotPlatform)/-/copilot-$(_CopilotPlatform)-$(CopilotCliVersion).tgz + <_CopilotNormalizedRegistryUrl>$([System.String]::Copy('$(CopilotNpmRegistryUrl)').TrimEnd('/')) + <_CopilotDownloadUrl>$(_CopilotNormalizedRegistryUrl)/@github/copilot-$(_CopilotPlatform)/-/copilot-$(_CopilotPlatform)-$(CopilotCliVersion).tgz @@ -45,6 +75,7 @@ @@ -59,7 +90,7 @@ - + <_CopilotCacheDir>$(IntermediateOutputPath)copilot-cli\$(CopilotCliVersion)\$(_CopilotPlatform) <_CopilotCliBinaryPath>$(_CopilotCacheDir)\$(_CopilotBinary) @@ -70,7 +101,7 @@ - + <_CopilotCacheDir>$(IntermediateOutputPath)copilot-cli\$(CopilotCliVersion)\$(_CopilotPlatform) <_CopilotCliBinaryPath>$(_CopilotCacheDir)\$(_CopilotBinary) diff --git a/dotnet/test/ClientTests.cs b/dotnet/test/ClientTests.cs index e3419f981..ee5b73bc7 100644 --- a/dotnet/test/ClientTests.cs +++ b/dotnet/test/ClientTests.cs @@ -215,4 +215,44 @@ public void Should_Throw_When_UseLoggedInUser_Used_With_CliUrl() }); }); } + + [Fact] + public async Task Should_Not_Throw_When_Disposing_Session_After_Stopping_Client() + { + await using var client = new CopilotClient(new CopilotClientOptions()); + await using var session = await client.CreateSessionAsync(); + + await client.StopAsync(); + } + + [Fact] + public async Task Should_Report_Error_With_Stderr_When_CLI_Fails_To_Start() + { + var client = new CopilotClient(new CopilotClientOptions + { + CliArgs = new[] { "--nonexistent-flag-for-testing" }, + UseStdio = true + }); + + var ex = await Assert.ThrowsAsync(async () => + { + await client.StartAsync(); + }); + + var errorMessage = ex.Message; + // Verify we get the stderr output in the error message + Assert.Contains("stderr", errorMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains("nonexistent", errorMessage, StringComparison.OrdinalIgnoreCase); + + // Verify subsequent calls also fail (don't hang) + var ex2 = await Assert.ThrowsAnyAsync(async () => + { + var session = await client.CreateSessionAsync(); + await session.SendAsync(new MessageOptions { Prompt = "test" }); + }); + Assert.Contains("exited", ex2.Message, StringComparison.OrdinalIgnoreCase); + + // Cleanup - ForceStop should handle the disconnected state gracefully + try { await client.ForceStopAsync(); } catch (Exception) { /* Expected */ } + } } diff --git a/dotnet/test/CloneTests.cs b/dotnet/test/CloneTests.cs new file mode 100644 index 000000000..45eaaae16 --- /dev/null +++ b/dotnet/test/CloneTests.cs @@ -0,0 +1,245 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using Microsoft.Extensions.AI; +using Xunit; + +namespace GitHub.Copilot.SDK.Test; + +public class CloneTests +{ + [Fact] + public void CopilotClientOptions_Clone_CopiesAllProperties() + { + var original = new CopilotClientOptions + { + CliPath = "/usr/bin/copilot", + CliArgs = ["--verbose", "--debug"], + Cwd = "/home/user", + Port = 8080, + UseStdio = false, + CliUrl = "http://localhost:8080", + LogLevel = "debug", + AutoStart = false, + AutoRestart = false, + Environment = new Dictionary { ["KEY"] = "value" }, + GithubToken = "ghp_test", + UseLoggedInUser = false, + }; + + var clone = original.Clone(); + + Assert.Equal(original.CliPath, clone.CliPath); + Assert.Equal(original.CliArgs, clone.CliArgs); + Assert.Equal(original.Cwd, clone.Cwd); + Assert.Equal(original.Port, clone.Port); + Assert.Equal(original.UseStdio, clone.UseStdio); + Assert.Equal(original.CliUrl, clone.CliUrl); + Assert.Equal(original.LogLevel, clone.LogLevel); + Assert.Equal(original.AutoStart, clone.AutoStart); + Assert.Equal(original.AutoRestart, clone.AutoRestart); + Assert.Equal(original.Environment, clone.Environment); + Assert.Equal(original.GithubToken, clone.GithubToken); + Assert.Equal(original.UseLoggedInUser, clone.UseLoggedInUser); + } + + [Fact] + public void CopilotClientOptions_Clone_CollectionsAreIndependent() + { + var original = new CopilotClientOptions + { + CliArgs = ["--verbose"], + }; + + var clone = original.Clone(); + + // Mutate clone array + clone.CliArgs![0] = "--quiet"; + + // Original is unaffected + Assert.Equal("--verbose", original.CliArgs![0]); + } + + [Fact] + public void CopilotClientOptions_Clone_EnvironmentIsShared() + { + var env = new Dictionary { ["key"] = "value" }; + var original = new CopilotClientOptions { Environment = env }; + + var clone = original.Clone(); + + Assert.Same(original.Environment, clone.Environment); + } + + [Fact] + public void SessionConfig_Clone_CopiesAllProperties() + { + var original = new SessionConfig + { + SessionId = "test-session", + ClientName = "my-app", + Model = "gpt-4", + ReasoningEffort = "high", + ConfigDir = "/config", + AvailableTools = ["tool1", "tool2"], + ExcludedTools = ["tool3"], + WorkingDirectory = "/workspace", + Streaming = true, + McpServers = new Dictionary { ["server1"] = new object() }, + CustomAgents = [new CustomAgentConfig { Name = "agent1" }], + SkillDirectories = ["/skills"], + DisabledSkills = ["skill1"], + }; + + var clone = original.Clone(); + + Assert.Equal(original.SessionId, clone.SessionId); + Assert.Equal(original.ClientName, clone.ClientName); + Assert.Equal(original.Model, clone.Model); + Assert.Equal(original.ReasoningEffort, clone.ReasoningEffort); + Assert.Equal(original.ConfigDir, clone.ConfigDir); + Assert.Equal(original.AvailableTools, clone.AvailableTools); + Assert.Equal(original.ExcludedTools, clone.ExcludedTools); + Assert.Equal(original.WorkingDirectory, clone.WorkingDirectory); + Assert.Equal(original.Streaming, clone.Streaming); + Assert.Equal(original.McpServers.Count, clone.McpServers!.Count); + Assert.Equal(original.CustomAgents.Count, clone.CustomAgents!.Count); + Assert.Equal(original.SkillDirectories, clone.SkillDirectories); + Assert.Equal(original.DisabledSkills, clone.DisabledSkills); + } + + [Fact] + public void SessionConfig_Clone_CollectionsAreIndependent() + { + var original = new SessionConfig + { + AvailableTools = ["tool1"], + ExcludedTools = ["tool2"], + McpServers = new Dictionary { ["s1"] = new object() }, + CustomAgents = [new CustomAgentConfig { Name = "a1" }], + SkillDirectories = ["/skills"], + DisabledSkills = ["skill1"], + }; + + var clone = original.Clone(); + + // Mutate clone collections + clone.AvailableTools!.Add("tool99"); + clone.ExcludedTools!.Add("tool99"); + clone.McpServers!["s2"] = new object(); + clone.CustomAgents!.Add(new CustomAgentConfig { Name = "a2" }); + clone.SkillDirectories!.Add("/more"); + clone.DisabledSkills!.Add("skill99"); + + // Original is unaffected + Assert.Single(original.AvailableTools!); + Assert.Single(original.ExcludedTools!); + Assert.Single(original.McpServers!); + Assert.Single(original.CustomAgents!); + Assert.Single(original.SkillDirectories!); + Assert.Single(original.DisabledSkills!); + } + + [Fact] + public void SessionConfig_Clone_PreservesMcpServersComparer() + { + var servers = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["server"] = new object() }; + var original = new SessionConfig { McpServers = servers }; + + var clone = original.Clone(); + + Assert.True(clone.McpServers!.ContainsKey("SERVER")); // case-insensitive lookup works + } + + [Fact] + public void ResumeSessionConfig_Clone_CollectionsAreIndependent() + { + var original = new ResumeSessionConfig + { + AvailableTools = ["tool1"], + ExcludedTools = ["tool2"], + McpServers = new Dictionary { ["s1"] = new object() }, + CustomAgents = [new CustomAgentConfig { Name = "a1" }], + SkillDirectories = ["/skills"], + DisabledSkills = ["skill1"], + }; + + var clone = original.Clone(); + + // Mutate clone collections + clone.AvailableTools!.Add("tool99"); + clone.ExcludedTools!.Add("tool99"); + clone.McpServers!["s2"] = new object(); + clone.CustomAgents!.Add(new CustomAgentConfig { Name = "a2" }); + clone.SkillDirectories!.Add("/more"); + clone.DisabledSkills!.Add("skill99"); + + // Original is unaffected + Assert.Single(original.AvailableTools!); + Assert.Single(original.ExcludedTools!); + Assert.Single(original.McpServers!); + Assert.Single(original.CustomAgents!); + Assert.Single(original.SkillDirectories!); + Assert.Single(original.DisabledSkills!); + } + + [Fact] + public void ResumeSessionConfig_Clone_PreservesMcpServersComparer() + { + var servers = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["server"] = new object() }; + var original = new ResumeSessionConfig { McpServers = servers }; + + var clone = original.Clone(); + + Assert.True(clone.McpServers!.ContainsKey("SERVER")); + } + + [Fact] + public void MessageOptions_Clone_CopiesAllProperties() + { + var original = new MessageOptions + { + Prompt = "Hello", + Attachments = [new UserMessageDataAttachmentsItemFile { Path = "/test.txt", DisplayName = "test.txt" }], + Mode = "chat", + }; + + var clone = original.Clone(); + + Assert.Equal(original.Prompt, clone.Prompt); + Assert.Equal(original.Mode, clone.Mode); + Assert.Single(clone.Attachments!); + } + + [Fact] + public void MessageOptions_Clone_AttachmentsAreIndependent() + { + var original = new MessageOptions + { + Attachments = [new UserMessageDataAttachmentsItemFile { Path = "/test.txt", DisplayName = "test.txt" }], + }; + + var clone = original.Clone(); + + clone.Attachments!.Add(new UserMessageDataAttachmentsItemFile { Path = "/other.txt", DisplayName = "other.txt" }); + + Assert.Single(original.Attachments!); + } + + [Fact] + public void Clone_WithNullCollections_ReturnsNullCollections() + { + var original = new SessionConfig(); + + var clone = original.Clone(); + + Assert.Null(clone.AvailableTools); + Assert.Null(clone.ExcludedTools); + Assert.Null(clone.McpServers); + Assert.Null(clone.CustomAgents); + Assert.Null(clone.SkillDirectories); + Assert.Null(clone.DisabledSkills); + Assert.Null(clone.Tools); + } +} diff --git a/dotnet/test/Harness/E2ETestContext.cs b/dotnet/test/Harness/E2ETestContext.cs index 2518ca69e..b8f3bdeb1 100644 --- a/dotnet/test/Harness/E2ETestContext.cs +++ b/dotnet/test/Harness/E2ETestContext.cs @@ -92,6 +92,7 @@ public IReadOnlyDictionary GetEnvironment() public CopilotClient CreateClient() => new(new CopilotClientOptions { Cwd = WorkDir, + CliPath = GetCliPath(_repoRoot), Environment = GetEnvironment(), GithubToken = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) ? "fake-token-for-e2e-tests" : null, }); diff --git a/dotnet/test/HooksTests.cs b/dotnet/test/HooksTests.cs index 34f6ecabf..44a6e66c2 100644 --- a/dotnet/test/HooksTests.cs +++ b/dotnet/test/HooksTests.cs @@ -17,6 +17,7 @@ public async Task Should_Invoke_PreToolUse_Hook_When_Model_Runs_A_Tool() CopilotSession? session = null; session = await Client.CreateSessionAsync(new SessionConfig { + OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks { OnPreToolUse = (input, invocation) => @@ -52,6 +53,7 @@ public async Task Should_Invoke_PostToolUse_Hook_After_Model_Runs_A_Tool() CopilotSession? session = null; session = await Client.CreateSessionAsync(new SessionConfig { + OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks { OnPostToolUse = (input, invocation) => @@ -89,6 +91,7 @@ public async Task Should_Invoke_Both_PreToolUse_And_PostToolUse_Hooks_For_Single var session = await Client.CreateSessionAsync(new SessionConfig { + OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks { OnPreToolUse = (input, invocation) => @@ -130,6 +133,7 @@ public async Task Should_Deny_Tool_Execution_When_PreToolUse_Returns_Deny() var session = await Client.CreateSessionAsync(new SessionConfig { + OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks { OnPreToolUse = (input, invocation) => diff --git a/dotnet/test/McpAndAgentsTests.cs b/dotnet/test/McpAndAgentsTests.cs index f24b7c8b6..644a70bf3 100644 --- a/dotnet/test/McpAndAgentsTests.cs +++ b/dotnet/test/McpAndAgentsTests.cs @@ -260,6 +260,42 @@ public async Task Should_Handle_Multiple_Custom_Agents() await session.DisposeAsync(); } + [Fact] + public async Task Should_Pass_Literal_Env_Values_To_Mcp_Server_Subprocess() + { + var testHarnessDir = FindTestHarnessDir(); + var mcpServers = new Dictionary + { + ["env-echo"] = new McpLocalServerConfig + { + Type = "local", + Command = "node", + Args = [Path.Combine(testHarnessDir, "test-mcp-server.mjs")], + Env = new Dictionary { ["TEST_SECRET"] = "hunter2" }, + Cwd = testHarnessDir, + Tools = ["*"] + } + }; + + var session = await Client.CreateSessionAsync(new SessionConfig + { + McpServers = mcpServers, + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); + + var message = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the env-echo/get_env tool to read the TEST_SECRET environment variable. Reply with just the value, nothing else." + }); + + Assert.NotNull(message); + Assert.Contains("hunter2", message!.Data.Content); + + await session.DisposeAsync(); + } + [Fact] public async Task Should_Accept_Both_MCP_Servers_And_Custom_Agents() { @@ -301,4 +337,17 @@ public async Task Should_Accept_Both_MCP_Servers_And_Custom_Agents() await session.DisposeAsync(); } + + private static string FindTestHarnessDir() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = Path.Combine(dir.FullName, "test", "harness", "test-mcp-server.mjs"); + if (File.Exists(candidate)) + return Path.GetDirectoryName(candidate)!; + dir = dir.Parent; + } + throw new InvalidOperationException("Could not find test/harness/test-mcp-server.mjs"); + } } diff --git a/dotnet/test/PermissionTests.cs b/dotnet/test/PermissionTests.cs index 237eb1f68..b1295be91 100644 --- a/dotnet/test/PermissionTests.cs +++ b/dotnet/test/PermissionTests.cs @@ -70,6 +70,30 @@ await session.SendAsync(new MessageOptions Assert.Equal("protected content", content); } + [Fact] + public async Task Should_Deny_Tool_Operations_By_Default_When_No_Handler_Is_Provided() + { + var session = await Client.CreateSessionAsync(new SessionConfig()); + var permissionDenied = false; + + session.On(evt => + { + if (evt is ToolExecutionCompleteEvent toolEvt && + !toolEvt.Data.Success && + toolEvt.Data.Error?.Message.Contains("Permission denied") == true) + { + permissionDenied = true; + } + }); + + await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Run 'node --version'" + }); + + Assert.True(permissionDenied, "Expected a tool.execution_complete event with Permission denied result"); + } + [Fact] public async Task Should_Work_Without_Permission_Handler__Default_Behavior_() { @@ -161,6 +185,37 @@ await session.SendAsync(new MessageOptions Assert.Matches("fail|cannot|unable|permission", message?.Data.Content?.ToLowerInvariant() ?? string.Empty); } + [Fact] + public async Task Should_Deny_Tool_Operations_By_Default_When_No_Handler_Is_Provided_After_Resume() + { + var session1 = await Client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll + }); + var sessionId = session1.SessionId; + await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); + + var session2 = await Client.ResumeSessionAsync(sessionId); + var permissionDenied = false; + + session2.On(evt => + { + if (evt is ToolExecutionCompleteEvent toolEvt && + !toolEvt.Data.Success && + toolEvt.Data.Error?.Message.Contains("Permission denied") == true) + { + permissionDenied = true; + } + }); + + await session2.SendAndWaitAsync(new MessageOptions + { + Prompt = "Run 'node --version'" + }); + + Assert.True(permissionDenied, "Expected a tool.execution_complete event with Permission denied result"); + } + [Fact] public async Task Should_Receive_ToolCallId_In_Permission_Requests() { diff --git a/dotnet/test/RpcTests.cs b/dotnet/test/RpcTests.cs new file mode 100644 index 000000000..818bc8760 --- /dev/null +++ b/dotnet/test/RpcTests.cs @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.SDK.Rpc; +using GitHub.Copilot.SDK.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.SDK.Test; + +public class RpcTests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "session", output) +{ + [Fact] + public async Task Should_Call_Rpc_Ping_With_Typed_Params_And_Result() + { + await Client.StartAsync(); + var result = await Client.Rpc.PingAsync(message: "typed rpc test"); + Assert.Equal("pong: typed rpc test", result.Message); + Assert.True(result.Timestamp >= 0); + } + + [Fact] + public async Task Should_Call_Rpc_Models_List_With_Typed_Result() + { + await Client.StartAsync(); + var authStatus = await Client.GetAuthStatusAsync(); + if (!authStatus.IsAuthenticated) + { + // Skip if not authenticated - models.list requires auth + return; + } + + var result = await Client.Rpc.Models.ListAsync(); + Assert.NotNull(result.Models); + } + + // account.getQuota is defined in schema but not yet implemented in CLI + [Fact(Skip = "account.getQuota not yet implemented in CLI")] + public async Task Should_Call_Rpc_Account_GetQuota_When_Authenticated() + { + await Client.StartAsync(); + var authStatus = await Client.GetAuthStatusAsync(); + if (!authStatus.IsAuthenticated) + { + // Skip if not authenticated - account.getQuota requires auth + return; + } + + var result = await Client.Rpc.Account.GetQuotaAsync(); + Assert.NotNull(result.QuotaSnapshots); + } + + // session.model.getCurrent is defined in schema but not yet implemented in CLI + [Fact(Skip = "session.model.getCurrent not yet implemented in CLI")] + public async Task Should_Call_Session_Rpc_Model_GetCurrent() + { + var session = await Client.CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); + + var result = await session.Rpc.Model.GetCurrentAsync(); + Assert.NotNull(result.ModelId); + Assert.NotEmpty(result.ModelId); + } + + // session.model.switchTo is defined in schema but not yet implemented in CLI + [Fact(Skip = "session.model.switchTo not yet implemented in CLI")] + public async Task Should_Call_Session_Rpc_Model_SwitchTo() + { + var session = await Client.CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); + + // Get initial model + var before = await session.Rpc.Model.GetCurrentAsync(); + Assert.NotNull(before.ModelId); + + // Switch to a different model + var result = await session.Rpc.Model.SwitchToAsync(modelId: "gpt-4.1"); + Assert.Equal("gpt-4.1", result.ModelId); + + // Verify the switch persisted + var after = await session.Rpc.Model.GetCurrentAsync(); + Assert.Equal("gpt-4.1", after.ModelId); + } + + [Fact] + public async Task Should_Get_And_Set_Session_Mode() + { + var session = await Client.CreateSessionAsync(); + + // Get initial mode (default should be interactive) + var initial = await session.Rpc.Mode.GetAsync(); + Assert.Equal(SessionModeGetResultMode.Interactive, initial.Mode); + + // Switch to plan mode + var planResult = await session.Rpc.Mode.SetAsync(SessionModeGetResultMode.Plan); + Assert.Equal(SessionModeGetResultMode.Plan, planResult.Mode); + + // Verify mode persisted + var afterPlan = await session.Rpc.Mode.GetAsync(); + Assert.Equal(SessionModeGetResultMode.Plan, afterPlan.Mode); + + // Switch back to interactive + var interactiveResult = await session.Rpc.Mode.SetAsync(SessionModeGetResultMode.Interactive); + Assert.Equal(SessionModeGetResultMode.Interactive, interactiveResult.Mode); + } + + [Fact] + public async Task Should_Read_Update_And_Delete_Plan() + { + var session = await Client.CreateSessionAsync(); + + // Initially plan should not exist + var initial = await session.Rpc.Plan.ReadAsync(); + Assert.False(initial.Exists); + Assert.Null(initial.Content); + + // Create/update plan + var planContent = "# Test Plan\n\n- Step 1\n- Step 2"; + await session.Rpc.Plan.UpdateAsync(planContent); + + // Verify plan exists and has correct content + var afterUpdate = await session.Rpc.Plan.ReadAsync(); + Assert.True(afterUpdate.Exists); + Assert.Equal(planContent, afterUpdate.Content); + + // Delete plan + await session.Rpc.Plan.DeleteAsync(); + + // Verify plan is deleted + var afterDelete = await session.Rpc.Plan.ReadAsync(); + Assert.False(afterDelete.Exists); + Assert.Null(afterDelete.Content); + } + + [Fact] + public async Task Should_Create_List_And_Read_Workspace_Files() + { + var session = await Client.CreateSessionAsync(); + + // Initially no files + var initialFiles = await session.Rpc.Workspace.ListFilesAsync(); + Assert.Empty(initialFiles.Files); + + // Create a file + var fileContent = "Hello, workspace!"; + await session.Rpc.Workspace.CreateFileAsync("test.txt", fileContent); + + // List files + var afterCreate = await session.Rpc.Workspace.ListFilesAsync(); + Assert.Contains("test.txt", afterCreate.Files); + + // Read file + var readResult = await session.Rpc.Workspace.ReadFileAsync("test.txt"); + Assert.Equal(fileContent, readResult.Content); + + // Create nested file + await session.Rpc.Workspace.CreateFileAsync("subdir/nested.txt", "Nested content"); + + var afterNested = await session.Rpc.Workspace.ListFilesAsync(); + Assert.Contains("test.txt", afterNested.Files); + Assert.Contains(afterNested.Files, f => f.Contains("nested.txt")); + } +} diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index 13b235226..c9a152ce9 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -333,7 +333,10 @@ public async Task Should_Receive_Session_Events() [Fact] public async Task Send_Returns_Immediately_While_Events_Stream_In_Background() { - var session = await Client.CreateSessionAsync(); + var session = await Client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); var events = new List(); session.On(evt => events.Add(evt.Type)); @@ -369,6 +372,25 @@ public async Task SendAndWait_Blocks_Until_Session_Idle_And_Returns_Final_Assist Assert.Contains("assistant.message", events); } + // TODO: Re-enable once test harness CAPI proxy supports this test's session lifecycle + [Fact(Skip = "Needs test harness CAPI proxy support")] + public async Task Should_List_Sessions_With_Context() + { + var session = await Client.CreateSessionAsync(); + + var sessions = await Client.ListSessionsAsync(); + Assert.NotEmpty(sessions); + + var ourSession = sessions.Find(s => s.SessionId == session.SessionId); + Assert.NotNull(ourSession); + + // Context may be present on sessions that have been persisted with workspace.yaml + if (ourSession.Context != null) + { + Assert.False(string.IsNullOrEmpty(ourSession.Context.Cwd), "Expected context.Cwd to be non-empty when context is present"); + } + } + [Fact] public async Task SendAndWait_Throws_On_Timeout() { diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index 3d7741c99..ad1ab7a21 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -21,7 +21,10 @@ await File.WriteAllTextAsync( Path.Combine(Ctx.WorkDir, "README.md"), "# ELIZA, the only chatbot you'll ever need"); - var session = await Client.CreateSessionAsync(); + var session = await Client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); await session.SendAsync(new MessageOptions { diff --git a/go/README.md b/go/README.md index 582071019..37cb7ce07 100644 --- a/go/README.md +++ b/go/README.md @@ -10,6 +10,15 @@ A Go SDK for programmatic access to the GitHub Copilot CLI. go get github.com/github/copilot-sdk/go ``` +## Run the Sample + +Try the interactive chat sample (from the repo root): + +```bash +cd go/samples +go run chat.go +``` + ## Quick Start ```go @@ -69,6 +78,18 @@ func main() { } ``` +## Distributing your application with an embedded GitHub Copilot CLI + +The SDK supports bundling, using Go's `embed` package, the Copilot CLI binary within your application's distribution. +This allows you to bundle a specific CLI version and avoid external dependencies on the user's system. + +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. + ## API Reference ### Client @@ -80,7 +101,7 @@ func main() { - `CreateSession(config *SessionConfig) (*Session, error)` - Create a new session - `ResumeSession(sessionID string) (*Session, error)` - Resume an existing session - `ResumeSessionWithOptions(sessionID string, config *ResumeSessionConfig) (*Session, error)` - Resume with additional configuration -- `ListSessions() ([]SessionMetadata, error)` - List all sessions known to the server +- `ListSessions(filter *SessionListFilter) ([]SessionMetadata, error)` - List sessions (with optional filter) - `DeleteSession(sessionID string) error` - Delete a session permanently - `GetState() ConnectionState` - Get connection state - `Ping(message string) (*PingResponse, error)` - Ping the server diff --git a/go/client.go b/go/client.go index 319c6588c..68f58d859 100644 --- a/go/client.go +++ b/go/client.go @@ -42,7 +42,9 @@ import ( "sync" "time" + "github.com/github/copilot-sdk/go/internal/embeddedcli" "github.com/github/copilot-sdk/go/internal/jsonrpc2" + "github.com/github/copilot-sdk/go/rpc" ) // Client manages the connection to the Copilot CLI server and provides session management. @@ -83,6 +85,12 @@ type Client struct { lifecycleHandlers []SessionLifecycleHandler typedLifecycleHandlers map[SessionLifecycleEventType][]SessionLifecycleHandler lifecycleHandlersMux sync.Mutex + processDone chan struct{} // closed when CLI process exits + processError error // set before processDone is closed + + // RPC provides typed server-scoped RPC methods. + // This field is nil until the client is connected via Start(). + RPC *rpc.ServerRpc } // NewClient creates a new Copilot CLI client with the given options. @@ -102,7 +110,7 @@ type Client struct { // }) func NewClient(options *ClientOptions) *Client { opts := ClientOptions{ - CLIPath: "copilot", + CLIPath: "", Cwd: "", Port: 0, LogLevel: "info", @@ -143,6 +151,9 @@ func NewClient(options *ClientOptions) *Client { 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 } @@ -336,6 +347,7 @@ func (c *Client) Stop() error { c.actualPort = 0 } + c.RPC = nil return errors.Join(errs...) } @@ -394,6 +406,8 @@ func (c *Client) ForceStop() { if !c.isExternalServer { c.actualPort = 0 } + + c.RPC = nil } func (c *Client) ensureConnected() error { @@ -441,6 +455,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config != nil { req.Model = config.Model req.SessionID = config.SessionID + req.ClientName = config.ClientName req.ReasoningEffort = config.ReasoningEffort req.ConfigDir = config.ConfigDir req.Tools = config.Tools @@ -450,6 +465,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.Provider = config.Provider req.WorkingDirectory = config.WorkingDirectory req.MCPServers = config.MCPServers + req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents req.SkillDirectories = config.SkillDirectories req.DisabledSkills = config.DisabledSkills @@ -458,9 +474,6 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses if config.Streaming { req.Streaming = Bool(true) } - if config.OnPermissionRequest != nil { - req.RequestPermission = Bool(true) - } if config.OnUserInputRequest != nil { req.RequestUserInput = Bool(true) } @@ -473,6 +486,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.Hooks = Bool(true) } } + req.RequestPermission = Bool(true) result, err := c.client.Request("session.create", req) if err != nil { @@ -537,6 +551,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, var req resumeSessionRequest req.SessionID = sessionID if config != nil { + req.ClientName = config.ClientName req.Model = config.Model req.ReasoningEffort = config.ReasoningEffort req.SystemMessage = config.SystemMessage @@ -547,9 +562,6 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, if config.Streaming { req.Streaming = Bool(true) } - if config.OnPermissionRequest != nil { - req.RequestPermission = Bool(true) - } if config.OnUserInputRequest != nil { req.RequestUserInput = Bool(true) } @@ -567,11 +579,13 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.DisableResume = Bool(true) } req.MCPServers = config.MCPServers + req.EnvValueMode = "direct" req.CustomAgents = config.CustomAgents req.SkillDirectories = config.SkillDirectories req.DisabledSkills = config.DisabledSkills req.InfiniteSessions = config.InfiniteSessions } + req.RequestPermission = Bool(true) result, err := c.client.Request("session.resume", req) if err != nil { @@ -609,23 +623,33 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, // ListSessions returns metadata about all sessions known to the server. // // Returns a list of SessionMetadata for all available sessions, including their IDs, -// timestamps, and optional summaries. +// timestamps, optional summaries, and context information. +// +// An optional filter can be provided to filter sessions by cwd, git root, repository, or branch. // // Example: // -// sessions, err := client.ListSessions(context.Background()) +// sessions, err := client.ListSessions(context.Background(), nil) // if err != nil { // log.Fatal(err) // } // for _, session := range sessions { // fmt.Printf("Session: %s\n", session.SessionID) // } -func (c *Client) ListSessions(ctx context.Context) ([]SessionMetadata, error) { +// +// Example with filter: +// +// sessions, err := client.ListSessions(context.Background(), &SessionListFilter{Repository: "owner/repo"}) +func (c *Client) ListSessions(ctx context.Context, filter *SessionListFilter) ([]SessionMetadata, error) { if err := c.ensureConnected(); err != nil { return nil, err } - result, err := c.client.Request("session.list", listSessionsRequest{}) + params := listSessionsRequest{} + if filter != nil { + params.Filter = filter + } + result, err := c.client.Request("session.list", params) if err != nil { return nil, err } @@ -994,7 +1018,19 @@ func (c *Client) verifyProtocolVersion(ctx context.Context) error { // This spawns the CLI server as a subprocess using the configured transport // mode (stdio or TCP). func (c *Client) startCLIServer(ctx context.Context) error { - args := []string{"--headless", "--no-auto-update", "--log-level", c.options.LogLevel} + cliPath := c.options.CLIPath + if cliPath == "" { + // If no CLI path is provided, attempt to use the embedded CLI if available + cliPath = embeddedcli.Path() + } + if cliPath == "" { + // Default to "copilot" in PATH if no embedded CLI is available and no custom path is set + cliPath = "copilot" + } + + // 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) // Choose transport mode if c.useStdio { @@ -1020,14 +1056,17 @@ func (c *Client) startCLIServer(ctx context.Context) error { // If CLIPath is a .js file, run it with node // Note we can't rely on the shebang as Windows doesn't support it - command := c.options.CLIPath - if strings.HasSuffix(c.options.CLIPath, ".js") { + command := cliPath + if strings.HasSuffix(cliPath, ".js") { command = "node" - args = append([]string{c.options.CLIPath}, args...) + args = append([]string{cliPath}, args...) } c.process = exec.CommandContext(ctx, command, args...) + // Configure platform-specific process attributes (e.g., hide window on Windows) + configureProcAttr(c.process) + // Set working directory if specified if c.options.Cwd != "" { c.process.Dir = c.options.Cwd @@ -1051,26 +1090,26 @@ func (c *Client) startCLIServer(ctx context.Context) error { return fmt.Errorf("failed to create stdout pipe: %w", err) } - stderr, err := c.process.StderrPipe() - if err != nil { - return fmt.Errorf("failed to create stderr pipe: %w", err) + if err := c.process.Start(); err != nil { + return fmt.Errorf("failed to start CLI server: %w", err) } - // Read stderr in background + // Monitor process exit to signal pending requests + c.processDone = make(chan struct{}) go func() { - scanner := bufio.NewScanner(stderr) - for scanner.Scan() { - // Optionally log stderr - // fmt.Fprintf(os.Stderr, "CLI stderr: %s\n", scanner.Text()) + waitErr := c.process.Wait() + if waitErr != nil { + c.processError = fmt.Errorf("CLI process exited: %v", waitErr) + } else { + c.processError = fmt.Errorf("CLI process exited unexpectedly") } + close(c.processDone) }() - if err := c.process.Start(); err != nil { - return fmt.Errorf("failed to start CLI server: %w", err) - } - // Create JSON-RPC client immediately c.client = jsonrpc2.NewClient(stdin, stdout) + c.client.SetProcessDone(c.processDone, &c.processError) + c.RPC = rpc.NewServerRpc(c.client) c.setupNotificationHandler() c.client.Start() @@ -1143,6 +1182,7 @@ func (c *Client) connectViaTcp(ctx context.Context) error { // Create JSON-RPC client with the connection c.client = jsonrpc2.NewClient(conn, conn) + c.RPC = rpc.NewServerRpc(c.client) c.setupNotificationHandler() c.client.Start() diff --git a/go/client_test.go b/go/client_test.go index 176dad8c5..b2e9cdce6 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1,6 +1,7 @@ package copilot import ( + "encoding/json" "os" "path/filepath" "reflect" @@ -389,3 +390,57 @@ func fileExistsForTest(path string) bool { _, err := os.Stat(path) return err == nil } + +func TestCreateSessionRequest_ClientName(t *testing.T) { + t.Run("includes clientName in JSON when set", func(t *testing.T) { + req := createSessionRequest{ClientName: "my-app"} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["clientName"] != "my-app" { + t.Errorf("Expected clientName to be 'my-app', got %v", m["clientName"]) + } + }) + + t.Run("omits clientName from JSON when empty", func(t *testing.T) { + req := createSessionRequest{} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["clientName"]; ok { + t.Error("Expected clientName to be omitted when empty") + } + }) +} + +func TestResumeSessionRequest_ClientName(t *testing.T) { + t.Run("includes clientName in JSON when set", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1", ClientName: "my-app"} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if m["clientName"] != "my-app" { + t.Errorf("Expected clientName to be 'my-app', got %v", m["clientName"]) + } + }) + + t.Run("omits clientName from JSON when empty", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1"} + data, _ := json.Marshal(req) + var m map[string]any + json.Unmarshal(data, &m) + if _, ok := m["clientName"]; ok { + t.Error("Expected clientName to be omitted when empty") + } + }) +} diff --git a/go/cmd/bundler/main.go b/go/cmd/bundler/main.go new file mode 100644 index 000000000..1e5f5ecd8 --- /dev/null +++ b/go/cmd/bundler/main.go @@ -0,0 +1,670 @@ +// Bundler downloads Copilot CLI binaries and packages them as a binary file, +// along with a Go source file that embeds the binary and metadata. +// +// Usage: +// +// go run github.com/github/copilot-sdk/go/cmd/bundler [--platform GOOS/GOARCH] [--output DIR] [--cli-version VERSION] [--check-only] +// +// --platform: Target platform using Go conventions (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64, windows/arm64). Defaults to current platform. +// --output: Output directory for embedded artifacts. Defaults to the current directory. +// --cli-version: CLI version to download. If not specified, automatically detects from the copilot-sdk version in go.mod. +// --check-only: Check that embedded CLI version matches the detected version from package-lock.json without downloading. Exits with error if versions don't match. +package main + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + + "github.com/klauspost/compress/zstd" +) + +const ( + // Keep these URLs centralized so reviewers can verify all outbound calls in one place. + sdkModule = "github.com/github/copilot-sdk/go" + packageLockURLFmt = "https://raw.githubusercontent.com/github/copilot-sdk/%s/nodejs/package-lock.json" + tarballURLFmt = "https://registry.npmjs.org/@github/copilot-%s/-/copilot-%s-%s.tgz" + licenseTarballFmt = "https://registry.npmjs.org/@github/copilot/-/copilot-%s.tgz" +) + +// Platform info: npm package suffix, binary name +type platformInfo struct { + npmPlatform string + binaryName string +} + +// Map from GOOS/GOARCH to npm platform info +var platforms = map[string]platformInfo{ + "linux/amd64": {npmPlatform: "linux-x64", binaryName: "copilot"}, + "linux/arm64": {npmPlatform: "linux-arm64", binaryName: "copilot"}, + "darwin/amd64": {npmPlatform: "darwin-x64", binaryName: "copilot"}, + "darwin/arm64": {npmPlatform: "darwin-arm64", binaryName: "copilot"}, + "windows/amd64": {npmPlatform: "win32-x64", binaryName: "copilot.exe"}, + "windows/arm64": {npmPlatform: "win32-arm64", binaryName: "copilot.exe"}, +} + +// main is the CLI entry point. +func main() { + platform := flag.String("platform", runtime.GOOS+"/"+runtime.GOARCH, "Target platform as GOOS/GOARCH (e.g. linux/amd64, darwin/arm64), defaults to current platform") + output := flag.String("output", "", "Output directory for embedded artifacts. Defaults to the current directory") + cliVersion := flag.String("cli-version", "", "CLI version to download (auto-detected from go.mod if not specified)") + checkOnly := flag.Bool("check-only", false, "Check that embedded CLI version matches the detected version from go.mod without downloading or updating the embedded files. Exits with error if versions don't match.") + flag.Parse() + + // Resolve version first so the default output name can include it. + version := resolveCLIVersion(*cliVersion) + // Resolve platform once to validate input and get the npm package mapping. + goos, goarch, info, err := resolvePlatform(*platform) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintf(os.Stderr, "Valid platforms: %s\n", strings.Join(validPlatforms(), ", ")) + os.Exit(1) + } + + outputPath := filepath.Join(*output, defaultOutputFileName(version, goos, goarch, info.binaryName)) + + if *checkOnly { + fmt.Printf("Check only: detected CLI version %s from go.mod\n", version) + fmt.Printf("Check only: verifying embedded version for %s\n", *platform) + + // Check if existing embedded version matches + if err := checkEmbeddedVersion(version, goos, goarch, *output); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + fmt.Println("Check only: embedded version matches detected version") + return + } + + fmt.Printf("Building bundle for %s (CLI version %s)\n", *platform, version) + + binaryPath, sha256Hash, err := buildBundle(info, version, outputPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Generate the Go file with embed directive + if err := generateGoFile(goos, goarch, binaryPath, version, sha256Hash, "main"); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if err := ensureZstdDependency(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +// resolvePlatform validates the platform flag and returns GOOS/GOARCH and mapping info. +func resolvePlatform(platform string) (string, string, platformInfo, error) { + goos, goarch, ok := strings.Cut(platform, "/") + if !ok || goos == "" || goarch == "" { + return "", "", platformInfo{}, fmt.Errorf("invalid platform %q", platform) + } + info, ok := platforms[platform] + if !ok { + return "", "", platformInfo{}, fmt.Errorf("invalid platform %q", platform) + } + return goos, goarch, info, nil +} + +// resolveCLIVersion determines the CLI version from the flag or repo metadata. +func resolveCLIVersion(flagValue string) string { + if flagValue != "" { + return flagValue + } + version, err := detectCLIVersion() + if err != nil { + fmt.Fprintf(os.Stderr, "Error detecting CLI version: %v\n", err) + fmt.Fprintln(os.Stderr, "Hint: specify --cli-version explicitly, or run from a Go module that depends on github.com/github/copilot-sdk/go") + os.Exit(1) + } + fmt.Printf("Auto-detected CLI version: %s\n", version) + return version +} + +// defaultOutputFileName builds the default bundle filename for a platform. +func defaultOutputFileName(version, goos, goarch, binaryName string) string { + base := strings.TrimSuffix(binaryName, filepath.Ext(binaryName)) + ext := filepath.Ext(binaryName) + return fmt.Sprintf("z%s_%s_%s_%s%s.zst", base, version, goos, goarch, ext) +} + +// validPlatforms returns valid platform keys for error messages. +func validPlatforms() []string { + result := make([]string, 0, len(platforms)) + for p := range platforms { + result = append(result, p) + } + return result +} + +// detectCLIVersion detects the CLI version by: +// 1. Running "go list -m" to get the copilot-sdk version from the user's go.mod +// 2. Fetching the package-lock.json from the SDK repo at that version +// 3. Extracting the @github/copilot CLI version from it +func detectCLIVersion() (string, error) { + // Get the SDK version from the user's go.mod + sdkVersion, err := getSDKVersion() + if err != nil { + return "", fmt.Errorf("failed to get SDK version: %w", err) + } + + fmt.Printf("Found copilot-sdk %s in go.mod\n", sdkVersion) + + // Fetch package-lock.json from the SDK repo at that version + cliVersion, err := fetchCLIVersionFromRepo(sdkVersion) + if err != nil { + return "", fmt.Errorf("failed to fetch CLI version: %w", err) + } + + return cliVersion, nil +} + +// getSDKVersion runs "go list -m" to get the copilot-sdk version from go.mod +func getSDKVersion() (string, error) { + cmd := exec.Command("go", "list", "-m", "-f", "{{.Version}}", sdkModule) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("go list failed: %s", string(exitErr.Stderr)) + } + return "", err + } + + version := strings.TrimSpace(string(output)) + if version == "" { + return "", fmt.Errorf("module %s not found in go.mod", sdkModule) + } + + return version, nil +} + +// fetchCLIVersionFromRepo fetches package-lock.json from GitHub and extracts the CLI version. +func fetchCLIVersionFromRepo(sdkVersion string) (string, error) { + // Convert Go module version to Git ref + // v0.1.0 -> v0.1.0 + // v0.1.0-beta.1 -> v0.1.0-beta.1 + // v0.0.0-20240101120000-abcdef123456 -> abcdef123456 (pseudo-version) + gitRef := sdkVersion + + // Pseudo-versions end with a 12-character commit hash. + // Format: vX.Y.Z-yyyymmddhhmmss-abcdefabcdef + if idx := strings.LastIndex(sdkVersion, "-"); idx != -1 { + suffix := sdkVersion[idx+1:] + // Use the commit hash when present so we fetch the exact source snapshot. + if len(suffix) == 12 && isHex(suffix) { + gitRef = suffix + } + } + + url := fmt.Sprintf(packageLockURLFmt, gitRef) + fmt.Printf("Fetching %s...\n", url) + + resp, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("failed to fetch: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to fetch package-lock.json: %s", resp.Status) + } + + var packageLock struct { + Packages map[string]struct { + Version string `json:"version"` + } `json:"packages"` + } + + if err := json.NewDecoder(resp.Body).Decode(&packageLock); err != nil { + return "", fmt.Errorf("failed to parse package-lock.json: %w", err) + } + + pkg, ok := packageLock.Packages["node_modules/@github/copilot"] + if !ok || pkg.Version == "" { + return "", fmt.Errorf("could not find @github/copilot version in package-lock.json") + } + + return pkg.Version, nil +} + +// isHex returns true if s contains only hexadecimal characters. +func isHex(s string) bool { + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return false + } + } + return true +} + +// buildBundle downloads the CLI binary and writes it to outputPath. +func buildBundle(info platformInfo, cliVersion, outputPath string) (string, []byte, error) { + outputDir := filepath.Dir(outputPath) + if outputDir == "" { + outputDir = "." + } + + // Check if output already exists + if _, err := os.Stat(outputPath); err == nil { + // Idempotent output avoids re-downloading in CI or local rebuilds. + fmt.Printf("Output %s already exists, skipping download\n", outputPath) + sha256Hash, err := sha256FileFromCompressed(outputPath) + if err != nil { + return "", nil, fmt.Errorf("failed to hash existing output: %w", err) + } + if err := downloadCLILicense(cliVersion, outputPath); err != nil { + return "", nil, fmt.Errorf("failed to download CLI license: %w", err) + } + return outputPath, sha256Hash, nil + } + // Create temp directory for download + tempDir, err := os.MkdirTemp("", "copilot-bundler-*") + if err != nil { + return "", nil, fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tempDir) + + // Download the binary + binaryPath, err := downloadCLIBinary(info.npmPlatform, info.binaryName, cliVersion, tempDir) + if err != nil { + return "", nil, fmt.Errorf("failed to download CLI binary: %w", err) + } + + // Create output directory if needed + if outputDir != "." { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return "", nil, fmt.Errorf("failed to create output directory: %w", err) + } + } + + sha256Hash, err := sha256File(binaryPath) + if err != nil { + return "", nil, fmt.Errorf("failed to hash output binary: %w", err) + } + if err := compressZstdFile(binaryPath, outputPath); err != nil { + return "", nil, fmt.Errorf("failed to write output binary: %w", err) + } + if err := downloadCLILicense(cliVersion, outputPath); err != nil { + return "", nil, fmt.Errorf("failed to download CLI license: %w", err) + } + fmt.Printf("Successfully created %s\n", outputPath) + return outputPath, sha256Hash, nil +} + +// generateGoFile creates a Go source file that embeds the binary and metadata. +func generateGoFile(goos, goarch, binaryPath, cliVersion string, sha256Hash []byte, pkgName string) error { + // Generate Go file path: zcopilot_linux_amd64.go (without version) + binaryName := filepath.Base(binaryPath) + licenseName := licenseFileName(binaryName) + goFileName := fmt.Sprintf("zcopilot_%s_%s.go", goos, goarch) + goFilePath := filepath.Join(filepath.Dir(binaryPath), goFileName) + hashBase64 := "" + if len(sha256Hash) > 0 { + hashBase64 = base64.StdEncoding.EncodeToString(sha256Hash) + } + + content := fmt.Sprintf(`// Code generated by copilot-sdk bundler; DO NOT EDIT. + +package %s + +import ( + "bytes" + "io" + "encoding/base64" + _ "embed" + + "github.com/github/copilot-sdk/go/embeddedcli" + "github.com/klauspost/compress/zstd" +) + +//go:embed %s +var localEmbeddedCopilotCLI []byte + +//go:embed %s +var localEmbeddedCopilotCLILicense []byte + + +func init() { + embeddedcli.Setup(embeddedcli.Config{ + Cli: cliReader(), + License: localEmbeddedCopilotCLILicense, + Version: %q, + CliHash: mustDecodeBase64(%q), + }) +} + +func cliReader() io.Reader { + r, err := zstd.NewReader(bytes.NewReader(localEmbeddedCopilotCLI)) + if err != nil { + panic("failed to create zstd reader: " + err.Error()) + } + return r +} + +func mustDecodeBase64(s string) []byte { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + panic("failed to decode base64: " + err.Error()) + } + return b +} +`, pkgName, binaryName, licenseName, cliVersion, hashBase64) + + if err := os.WriteFile(goFilePath, []byte(content), 0644); err != nil { + return err + } + + fmt.Printf("Generated %s\n", goFilePath) + return nil +} + +// downloadCLIBinary downloads the npm tarball and extracts the CLI binary. +func downloadCLIBinary(npmPlatform, binaryName, cliVersion, destDir string) (string, error) { + tarballURL := fmt.Sprintf(tarballURLFmt, npmPlatform, npmPlatform, cliVersion) + + fmt.Printf("Downloading from %s...\n", tarballURL) + + resp, err := http.Get(tarballURL) + if err != nil { + return "", fmt.Errorf("failed to download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to download: %s", resp.Status) + } + + // Save tarball to temp file + tarballPath := filepath.Join(destDir, fmt.Sprintf("copilot-%s-%s.tgz", npmPlatform, cliVersion)) + tarballFile, err := os.Create(tarballPath) + if err != nil { + return "", fmt.Errorf("failed to create tarball file: %w", err) + } + + if _, err := io.Copy(tarballFile, resp.Body); err != nil { + tarballFile.Close() + return "", fmt.Errorf("failed to save tarball: %w", err) + } + if err := tarballFile.Close(); err != nil { + return "", fmt.Errorf("failed to close tarball file: %w", err) + } + + // Extract only the CLI binary to avoid unpacking the full package tree. + binaryPath := filepath.Join(destDir, binaryName) + if err := extractFileFromTarball(tarballPath, destDir, "package/"+binaryName, binaryName); err != nil { + return "", fmt.Errorf("failed to extract binary: %w", err) + } + + // Verify binary exists + if _, err := os.Stat(binaryPath); err != nil { + return "", fmt.Errorf("binary not found after extraction: %w", err) + } + + // Make executable on Unix + if !strings.HasSuffix(binaryName, ".exe") { + if err := os.Chmod(binaryPath, 0755); err != nil { + return "", fmt.Errorf("failed to chmod binary: %w", err) + } + } + + stat, err := os.Stat(binaryPath) + if err != nil { + return "", fmt.Errorf("failed to stat binary: %w", err) + } + sizeMB := float64(stat.Size()) / 1024 / 1024 + fmt.Printf("Downloaded %s (%.1f MB)\n", binaryName, sizeMB) + + return binaryPath, nil +} + +// downloadCLILicense downloads the @github/copilot package and writes its license next to outputPath. +func downloadCLILicense(cliVersion, outputPath string) error { + outputDir := filepath.Dir(outputPath) + if outputDir == "" { + outputDir = "." + } + licensePath := licensePathForOutput(outputPath) + if _, err := os.Stat(licensePath); err == nil { + return nil + } + + licenseURL := fmt.Sprintf(licenseTarballFmt, cliVersion) + resp, err := http.Get(licenseURL) + if err != nil { + return fmt.Errorf("failed to download license tarball: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download license tarball: %s", resp.Status) + } + + gzReader, err := gzip.NewReader(resp.Body) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read tar: %w", err) + } + switch header.Name { + case "package/LICENSE.md", "package/LICENSE": + licenseName := filepath.Base(licensePath) + if err := extractFileFromTarballStream(tarReader, outputDir, licenseName, os.FileMode(header.Mode)); err != nil { + return fmt.Errorf("failed to write license: %w", err) + } + return nil + } + } + + return fmt.Errorf("license file not found in tarball") +} + +func licensePathForOutput(outputPath string) string { + if strings.HasSuffix(outputPath, ".zst") { + return strings.TrimSuffix(outputPath, ".zst") + ".license" + } + return outputPath + ".license" +} + +func licenseFileName(binaryName string) string { + if strings.HasSuffix(binaryName, ".zst") { + return strings.TrimSuffix(binaryName, ".zst") + ".license" + } + return binaryName + ".license" +} + +// extractFileFromTarballStream writes the current tar entry to disk. +func extractFileFromTarballStream(r io.Reader, destDir, outputName string, mode os.FileMode) error { + outPath := filepath.Join(destDir, outputName) + outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + if _, err := io.Copy(outFile, r); err != nil { + if cerr := outFile.Close(); cerr != nil { + return fmt.Errorf("failed to extract license: copy error: %v; close error: %w", err, cerr) + } + return fmt.Errorf("failed to extract license: %w", err) + } + return outFile.Close() +} + +// extractFileFromTarball extracts a single file from a .tgz into destDir with a new name. +func extractFileFromTarball(tarballPath, destDir, targetPath, outputName string) error { + file, err := os.Open(tarballPath) + if err != nil { + return err + } + defer file.Close() + + gzReader, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read tar: %w", err) + } + + if header.Name == targetPath { + outPath := filepath.Join(destDir, outputName) + outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + + if _, err := io.Copy(outFile, tarReader); err != nil { + if cerr := outFile.Close(); cerr != nil { + return fmt.Errorf("failed to extract binary (copy error: %v, close error: %v)", err, cerr) + } + return fmt.Errorf("failed to extract binary: %w", err) + } + if err := outFile.Close(); err != nil { + return fmt.Errorf("failed to close output file: %w", err) + } + return nil + } + } + + return fmt.Errorf("file %q not found in tarball", targetPath) +} + +// compressZstdFile compresses src into dst using zstd. +func compressZstdFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + dstFile, err := os.Create(dst) + if err != nil { + return err + } + defer dstFile.Close() + + writer, err := zstd.NewWriter(dstFile) + if err != nil { + return err + } + defer writer.Close() + + if _, err := io.Copy(writer, srcFile); err != nil { + return err + } + return writer.Close() +} + +// sha256HexFileFromCompressed returns SHA-256 of the decompressed zstd stream. +func sha256FileFromCompressed(path string) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + reader, err := zstd.NewReader(file) + if err != nil { + return nil, err + } + defer reader.Close() + + h := sha256.New() + if _, err := io.Copy(h, reader); err != nil { + return nil, err + } + return h.Sum(nil), nil +} + +// sha256File returns the SHA-256 hash of a file as raw bytes. +func sha256File(path string) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + h := sha256.New() + if _, err := io.Copy(h, file); err != nil { + return nil, err + } + return h.Sum(nil), nil +} + +// ensureZstdDependency makes sure the module has the zstd dependency for generated code. +func ensureZstdDependency() error { + cmd := exec.Command("go", "mod", "tidy") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to add zstd dependency: %w\n%s", err, strings.TrimSpace(string(output))) + } + return nil +} + +// checkEmbeddedVersion checks if an embedded CLI version exists and compares it with the detected version. +func checkEmbeddedVersion(detectedVersion, goos, goarch, outputDir string) error { + // Look for the generated Go file for this platform + goFileName := fmt.Sprintf("zcopilot_%s_%s.go", goos, goarch) + goFilePath := filepath.Join(outputDir, goFileName) + + data, err := os.ReadFile(goFilePath) + if err != nil { + if os.IsNotExist(err) { + // No existing embedded version, nothing to check + return nil + } + return fmt.Errorf("failed to read existing Go file: %w", err) + } + + // Extract version from the generated file + // Looking for: Version: "x.y.z", + re := regexp.MustCompile(`Version:\s*"([^"]+)"`) + matches := re.FindSubmatch(data) + if matches == nil { + // Can't parse version, skip check + return nil + } + + embeddedVersion := string(matches[1]) + fmt.Printf("Found existing embedded version: %s\n", embeddedVersion) + + // Compare versions + if embeddedVersion != detectedVersion { + return fmt.Errorf("embedded version %s does not match detected version %s - update required", embeddedVersion, detectedVersion) + } + + fmt.Printf("Embedded version is up to date (%s)\n", embeddedVersion) + return nil +} diff --git a/go/embeddedcli/installer.go b/go/embeddedcli/installer.go new file mode 100644 index 000000000..deb4c2eef --- /dev/null +++ b/go/embeddedcli/installer.go @@ -0,0 +1,17 @@ +package embeddedcli + +import "github.com/github/copilot-sdk/go/internal/embeddedcli" + +// Config defines the inputs used to install and locate the embedded Copilot CLI. +// +// Cli and CliHash are required. If Dir is empty, the CLI is installed into the +// system cache directory. Version is used to suffix the installed binary name to +// allow multiple versions to coexist. License, when provided, is written next +// to the installed binary. +type Config = embeddedcli.Config + +// Setup sets the embedded GitHub Copilot CLI install configuration. +// The CLI will be lazily installed when needed. +func Setup(cfg Config) { + embeddedcli.Setup(cfg) +} diff --git a/go/generated_session_events.go b/go/generated_session_events.go index ec4de9bea..c11a43c5a 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -1,12 +1,5 @@ // AUTO-GENERATED FILE - DO NOT EDIT -// -// Generated from: @github/copilot/session-events.schema.json -// Generated by: scripts/generate-session-types.ts -// Generated at: 2026-02-06T20:38:23.463Z -// -// To update these types: -// 1. Update the schema in copilot-agent-runtime -// 2. Run: npm run generate:session-types +// Generated from: session-events.schema.json // Code generated from JSON Schema using quicktype. DO NOT EDIT. // To parse and unparse this JSON data, add this code to your project and do: @@ -42,26 +35,33 @@ type SessionEvent struct { } type Data struct { - Context *ContextUnion `json:"context"` - CopilotVersion *string `json:"copilotVersion,omitempty"` - Producer *string `json:"producer,omitempty"` - SelectedModel *string `json:"selectedModel,omitempty"` - SessionID *string `json:"sessionId,omitempty"` - StartTime *time.Time `json:"startTime,omitempty"` - Version *float64 `json:"version,omitempty"` - EventCount *float64 `json:"eventCount,omitempty"` - ResumeTime *time.Time `json:"resumeTime,omitempty"` - ErrorType *string `json:"errorType,omitempty"` - Message *string `json:"message,omitempty"` - ProviderCallID *string `json:"providerCallId,omitempty"` - Stack *string `json:"stack,omitempty"` - StatusCode *int64 `json:"statusCode,omitempty"` - InfoType *string `json:"infoType,omitempty"` - NewModel *string `json:"newModel,omitempty"` - PreviousModel *string `json:"previousModel,omitempty"` + Context *ContextUnion `json:"context"` + CopilotVersion *string `json:"copilotVersion,omitempty"` + Producer *string `json:"producer,omitempty"` + SelectedModel *string `json:"selectedModel,omitempty"` + SessionID *string `json:"sessionId,omitempty"` + StartTime *time.Time `json:"startTime,omitempty"` + Version *float64 `json:"version,omitempty"` + EventCount *float64 `json:"eventCount,omitempty"` + ResumeTime *time.Time `json:"resumeTime,omitempty"` + ErrorType *string `json:"errorType,omitempty"` + Message *string `json:"message,omitempty"` + ProviderCallID *string `json:"providerCallId,omitempty"` + Stack *string `json:"stack,omitempty"` + StatusCode *int64 `json:"statusCode,omitempty"` + Title *string `json:"title,omitempty"` + InfoType *string `json:"infoType,omitempty"` + WarningType *string `json:"warningType,omitempty"` + NewModel *string `json:"newModel,omitempty"` + PreviousModel *string `json:"previousModel,omitempty"` + NewMode *string `json:"newMode,omitempty"` + PreviousMode *string `json:"previousMode,omitempty"` + Operation *Operation `json:"operation,omitempty"` + // Relative path within the workspace files directory + Path *string `json:"path,omitempty"` HandoffTime *time.Time `json:"handoffTime,omitempty"` RemoteSessionID *string `json:"remoteSessionId,omitempty"` - Repository *Repository `json:"repository,omitempty"` + Repository *RepositoryUnion `json:"repository"` SourceType *SourceType `json:"sourceType,omitempty"` Summary *string `json:"summary,omitempty"` MessagesRemovedDuringTruncation *float64 `json:"messagesRemovedDuringTruncation,omitempty"` @@ -82,6 +82,9 @@ type Data struct { ShutdownType *ShutdownType `json:"shutdownType,omitempty"` TotalAPIDurationMS *float64 `json:"totalApiDurationMs,omitempty"` TotalPremiumRequests *float64 `json:"totalPremiumRequests,omitempty"` + Branch *string `json:"branch,omitempty"` + Cwd *string `json:"cwd,omitempty"` + GitRoot *string `json:"gitRoot,omitempty"` CurrentTokens *float64 `json:"currentTokens,omitempty"` MessagesLength *float64 `json:"messagesLength,omitempty"` CheckpointNumber *float64 `json:"checkpointNumber,omitempty"` @@ -96,6 +99,7 @@ type Data struct { Success *bool `json:"success,omitempty"` SummaryContent *string `json:"summaryContent,omitempty"` TokensRemoved *float64 `json:"tokensRemoved,omitempty"` + AgentMode *AgentMode `json:"agentMode,omitempty"` Attachments []Attachment `json:"attachments,omitempty"` Content *string `json:"content,omitempty"` Source *string `json:"source,omitempty"` @@ -107,6 +111,7 @@ type Data struct { EncryptedContent *string `json:"encryptedContent,omitempty"` MessageID *string `json:"messageId,omitempty"` ParentToolCallID *string `json:"parentToolCallId,omitempty"` + Phase *string `json:"phase,omitempty"` ReasoningOpaque *string `json:"reasoningOpaque,omitempty"` ReasoningText *string `json:"reasoningText,omitempty"` ToolRequests []ToolRequest `json:"toolRequests,omitempty"` @@ -134,7 +139,6 @@ type Data struct { ToolTelemetry map[string]interface{} `json:"toolTelemetry,omitempty"` AllowedTools []string `json:"allowedTools,omitempty"` Name *string `json:"name,omitempty"` - Path *string `json:"path,omitempty"` AgentDescription *string `json:"agentDescription,omitempty"` AgentDisplayName *string `json:"agentDisplayName,omitempty"` AgentName *string `json:"agentName,omitempty"` @@ -149,6 +153,7 @@ type Data struct { type Attachment struct { DisplayName string `json:"displayName"` + LineRange *LineRange `json:"lineRange,omitempty"` Path *string `json:"path,omitempty"` Type AttachmentType `json:"type"` FilePath *string `json:"filePath,omitempty"` @@ -156,6 +161,11 @@ type Attachment struct { Text *string `json:"text,omitempty"` } +type LineRange struct { + End float64 `json:"end"` + Start float64 `json:"start"` +} + type SelectionClass struct { End End `json:"end"` Start Start `json:"start"` @@ -229,15 +239,46 @@ type QuotaSnapshot struct { UsedRequests float64 `json:"usedRequests"` } -type Repository struct { +type RepositoryClass struct { Branch *string `json:"branch,omitempty"` Name string `json:"name"` Owner string `json:"owner"` } type Result struct { - Content string `json:"content"` - DetailedContent *string `json:"detailedContent,omitempty"` + Content string `json:"content"` + Contents []Content `json:"contents,omitempty"` + DetailedContent *string `json:"detailedContent,omitempty"` +} + +type Content struct { + Text *string `json:"text,omitempty"` + Type ContentType `json:"type"` + Cwd *string `json:"cwd,omitempty"` + ExitCode *float64 `json:"exitCode,omitempty"` + Data *string `json:"data,omitempty"` + MIMEType *string `json:"mimeType,omitempty"` + Description *string `json:"description,omitempty"` + Icons []Icon `json:"icons,omitempty"` + Name *string `json:"name,omitempty"` + Size *float64 `json:"size,omitempty"` + Title *string `json:"title,omitempty"` + URI *string `json:"uri,omitempty"` + Resource *ResourceClass `json:"resource,omitempty"` +} + +type Icon struct { + MIMEType *string `json:"mimeType,omitempty"` + Sizes []string `json:"sizes,omitempty"` + Src string `json:"src"` + Theme *Theme `json:"theme,omitempty"` +} + +type ResourceClass struct { + MIMEType *string `json:"mimeType,omitempty"` + Text *string `json:"text,omitempty"` + URI string `json:"uri"` + Blob *string `json:"blob,omitempty"` } type ToolRequest struct { @@ -247,6 +288,15 @@ type ToolRequest struct { Type *ToolRequestType `json:"type,omitempty"` } +type AgentMode string + +const ( + Autopilot AgentMode = "autopilot" + Interactive AgentMode = "interactive" + Plan AgentMode = "plan" + Shell AgentMode = "shell" +) + type AttachmentType string const ( @@ -255,6 +305,32 @@ const ( Selection AttachmentType = "selection" ) +type Operation string + +const ( + Create Operation = "create" + Delete Operation = "delete" + Update Operation = "update" +) + +type Theme string + +const ( + Dark Theme = "dark" + Light Theme = "light" +) + +type ContentType string + +const ( + Audio ContentType = "audio" + Image ContentType = "image" + Resource ContentType = "resource" + ResourceLink ContentType = "resource_link" + Terminal ContentType = "terminal" + Text ContentType = "text" +) + type Role string const ( @@ -286,43 +362,49 @@ const ( type SessionEventType string const ( - Abort SessionEventType = "abort" - AssistantIntent SessionEventType = "assistant.intent" - AssistantMessage SessionEventType = "assistant.message" - AssistantMessageDelta SessionEventType = "assistant.message_delta" - AssistantReasoning SessionEventType = "assistant.reasoning" - AssistantReasoningDelta SessionEventType = "assistant.reasoning_delta" - AssistantTurnEnd SessionEventType = "assistant.turn_end" - AssistantTurnStart SessionEventType = "assistant.turn_start" - AssistantUsage SessionEventType = "assistant.usage" - HookEnd SessionEventType = "hook.end" - HookStart SessionEventType = "hook.start" - PendingMessagesModified SessionEventType = "pending_messages.modified" - SessionCompactionComplete SessionEventType = "session.compaction_complete" - SessionCompactionStart SessionEventType = "session.compaction_start" - SessionError SessionEventType = "session.error" - SessionHandoff SessionEventType = "session.handoff" - SessionIdle SessionEventType = "session.idle" - SessionInfo SessionEventType = "session.info" - SessionModelChange SessionEventType = "session.model_change" - SessionResume SessionEventType = "session.resume" - SessionShutdown SessionEventType = "session.shutdown" - SessionSnapshotRewind SessionEventType = "session.snapshot_rewind" - SessionStart SessionEventType = "session.start" - SessionTruncation SessionEventType = "session.truncation" - SessionUsageInfo SessionEventType = "session.usage_info" - SkillInvoked SessionEventType = "skill.invoked" - SubagentCompleted SessionEventType = "subagent.completed" - SubagentFailed SessionEventType = "subagent.failed" - SubagentSelected SessionEventType = "subagent.selected" - SubagentStarted SessionEventType = "subagent.started" - SystemMessage SessionEventType = "system.message" - ToolExecutionComplete SessionEventType = "tool.execution_complete" - ToolExecutionPartialResult SessionEventType = "tool.execution_partial_result" - ToolExecutionProgress SessionEventType = "tool.execution_progress" - ToolExecutionStart SessionEventType = "tool.execution_start" - ToolUserRequested SessionEventType = "tool.user_requested" - UserMessage SessionEventType = "user.message" + Abort SessionEventType = "abort" + AssistantIntent SessionEventType = "assistant.intent" + AssistantMessage SessionEventType = "assistant.message" + AssistantMessageDelta SessionEventType = "assistant.message_delta" + AssistantReasoning SessionEventType = "assistant.reasoning" + AssistantReasoningDelta SessionEventType = "assistant.reasoning_delta" + AssistantTurnEnd SessionEventType = "assistant.turn_end" + AssistantTurnStart SessionEventType = "assistant.turn_start" + AssistantUsage SessionEventType = "assistant.usage" + HookEnd SessionEventType = "hook.end" + HookStart SessionEventType = "hook.start" + PendingMessagesModified SessionEventType = "pending_messages.modified" + SessionCompactionComplete SessionEventType = "session.compaction_complete" + SessionCompactionStart SessionEventType = "session.compaction_start" + SessionContextChanged SessionEventType = "session.context_changed" + SessionError SessionEventType = "session.error" + SessionHandoff SessionEventType = "session.handoff" + SessionIdle SessionEventType = "session.idle" + SessionInfo SessionEventType = "session.info" + SessionModeChanged SessionEventType = "session.mode_changed" + SessionModelChange SessionEventType = "session.model_change" + SessionPlanChanged SessionEventType = "session.plan_changed" + SessionResume SessionEventType = "session.resume" + SessionShutdown SessionEventType = "session.shutdown" + SessionSnapshotRewind SessionEventType = "session.snapshot_rewind" + SessionStart SessionEventType = "session.start" + SessionTitleChanged SessionEventType = "session.title_changed" + SessionTruncation SessionEventType = "session.truncation" + SessionUsageInfo SessionEventType = "session.usage_info" + SessionWarning SessionEventType = "session.warning" + SessionWorkspaceFileChanged SessionEventType = "session.workspace_file_changed" + SkillInvoked SessionEventType = "skill.invoked" + SubagentCompleted SessionEventType = "subagent.completed" + SubagentFailed SessionEventType = "subagent.failed" + SubagentSelected SessionEventType = "subagent.selected" + SubagentStarted SessionEventType = "subagent.started" + SystemMessage SessionEventType = "system.message" + ToolExecutionComplete SessionEventType = "tool.execution_complete" + ToolExecutionPartialResult SessionEventType = "tool.execution_partial_result" + ToolExecutionProgress SessionEventType = "tool.execution_progress" + ToolExecutionStart SessionEventType = "tool.execution_start" + ToolUserRequested SessionEventType = "tool.user_requested" + UserMessage SessionEventType = "user.message" ) type ContextUnion struct { @@ -369,6 +451,28 @@ func (x *ErrorUnion) MarshalJSON() ([]byte, error) { return marshalUnion(nil, nil, nil, x.String, false, nil, x.ErrorClass != nil, x.ErrorClass, false, nil, false, nil, false) } +type RepositoryUnion struct { + RepositoryClass *RepositoryClass + String *string +} + +func (x *RepositoryUnion) UnmarshalJSON(data []byte) error { + x.RepositoryClass = nil + var c RepositoryClass + object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, false) + if err != nil { + return err + } + if object { + x.RepositoryClass = &c + } + return nil +} + +func (x *RepositoryUnion) MarshalJSON() ([]byte, error) { + return marshalUnion(nil, nil, nil, x.String, false, nil, x.RepositoryClass != nil, x.RepositoryClass, false, nil, false, nil, false) +} + func unmarshalUnion(data []byte, pi **int64, pf **float64, pb **bool, ps **string, haveArray bool, pa interface{}, haveObject bool, pc interface{}, haveMap bool, pm interface{}, haveEnum bool, pe interface{}, nullable bool) (bool, error) { if pi != nil { *pi = nil diff --git a/go/go.mod b/go/go.mod index 8287b0474..c835cc889 100644 --- a/go/go.mod +++ b/go/go.mod @@ -2,4 +2,7 @@ module github.com/github/copilot-sdk/go go 1.24 -require github.com/google/jsonschema-go v0.4.2 +require ( + github.com/google/jsonschema-go v0.4.2 + github.com/klauspost/compress v1.18.3 +) diff --git a/go/go.sum b/go/go.sum index 6e171099c..0cc670e8f 100644 --- a/go/go.sum +++ b/go/go.sum @@ -2,3 +2,5 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= diff --git a/go/internal/e2e/client_test.go b/go/internal/e2e/client_test.go index d82b09264..8f5cf2495 100644 --- a/go/internal/e2e/client_test.go +++ b/go/internal/e2e/client_test.go @@ -225,4 +225,27 @@ func TestClient(t *testing.T) { client.Stop() }) + + 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), + }) + t.Cleanup(func() { client.ForceStop() }) + + err := client.Start(t.Context()) + if err == nil { + t.Fatal("Expected Start to fail with invalid CLI args") + } + + // Verify subsequent calls also fail (don't hang) + session, err := client.CreateSession(t.Context(), nil) + if err == nil { + _, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "test"}) + } + if err == nil { + t.Fatal("Expected CreateSession/Send to fail after CLI exit") + } + }) } diff --git a/go/internal/e2e/hooks_test.go b/go/internal/e2e/hooks_test.go index 9f1a9ec05..70aa6ec71 100644 --- a/go/internal/e2e/hooks_test.go +++ b/go/internal/e2e/hooks_test.go @@ -22,6 +22,7 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { mu.Lock() @@ -80,6 +81,7 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Hooks: &copilot.SessionHooks{ OnPostToolUse: func(input copilot.PostToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { mu.Lock() @@ -145,6 +147,7 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { mu.Lock() @@ -214,6 +217,7 @@ func TestHooks(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Hooks: &copilot.SessionHooks{ OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { mu.Lock() diff --git a/go/internal/e2e/mcp_and_agents_test.go b/go/internal/e2e/mcp_and_agents_test.go index 1d21651be..f8325b9f4 100644 --- a/go/internal/e2e/mcp_and_agents_test.go +++ b/go/internal/e2e/mcp_and_agents_test.go @@ -1,6 +1,7 @@ package e2e import ( + "path/filepath" "strings" "testing" @@ -104,6 +105,52 @@ func TestMCPServers(t *testing.T) { session2.Destroy() }) + t.Run("should pass literal env values to MCP server subprocess", func(t *testing.T) { + ctx.ConfigureForTest(t) + + mcpServerPath, err := filepath.Abs("../../../test/harness/test-mcp-server.mjs") + if err != nil { + t.Fatalf("Failed to resolve test-mcp-server path: %v", err) + } + mcpServerDir := filepath.Dir(mcpServerPath) + + mcpServers := map[string]copilot.MCPServerConfig{ + "env-echo": { + "type": "local", + "command": "node", + "args": []string{mcpServerPath}, + "tools": []string{"*"}, + "env": map[string]string{"TEST_SECRET": "hunter2"}, + "cwd": mcpServerDir, + }, + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + MCPServers: mcpServers, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + if session.SessionID == "" { + t.Error("Expected non-empty session ID") + } + + message, err := session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the env-echo/get_env tool to read the TEST_SECRET environment variable. Reply with just the value, nothing else.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + if message.Data.Content == nil || !strings.Contains(*message.Data.Content, "hunter2") { + t.Errorf("Expected message to contain 'hunter2', got: %v", message.Data.Content) + } + + session.Destroy() + }) + t.Run("handle multiple MCP servers", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/go/internal/e2e/permissions_test.go b/go/internal/e2e/permissions_test.go index a891548c7..1584f0244 100644 --- a/go/internal/e2e/permissions_test.go +++ b/go/internal/e2e/permissions_test.go @@ -157,6 +157,87 @@ func TestPermissions(t *testing.T) { } }) + t.Run("should deny tool operations by default when no handler is provided", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), nil) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + var mu sync.Mutex + permissionDenied := false + + session.On(func(event copilot.SessionEvent) { + if event.Type == copilot.ToolExecutionComplete && + event.Data.Success != nil && !*event.Data.Success && + event.Data.Error != nil && event.Data.Error.ErrorClass != nil && + strings.Contains(event.Data.Error.ErrorClass.Message, "Permission denied") { + mu.Lock() + permissionDenied = true + mu.Unlock() + } + }) + + if _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Run 'node --version'", + }); err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if !permissionDenied { + t.Error("Expected a tool.execution_complete event with Permission denied result") + } + }) + + t.Run("should deny tool operations by default when no handler is provided after resume", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + sessionID := session1.SessionID + if _, err = session1.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "What is 1+1?"}); err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + session2, err := client.ResumeSession(t.Context(), sessionID) + if err != nil { + t.Fatalf("Failed to resume session: %v", err) + } + + var mu sync.Mutex + permissionDenied := false + + session2.On(func(event copilot.SessionEvent) { + if event.Type == copilot.ToolExecutionComplete && + event.Data.Success != nil && !*event.Data.Success && + event.Data.Error != nil && event.Data.Error.ErrorClass != nil && + strings.Contains(event.Data.Error.ErrorClass.Message, "Permission denied") { + mu.Lock() + permissionDenied = true + mu.Unlock() + } + }) + + if _, err = session2.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Run 'node --version'", + }); err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if !permissionDenied { + t.Error("Expected a tool.execution_complete event with Permission denied result") + } + }) + t.Run("without permission handler", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/go/internal/e2e/rpc_test.go b/go/internal/e2e/rpc_test.go new file mode 100644 index 000000000..43b7cafa8 --- /dev/null +++ b/go/internal/e2e/rpc_test.go @@ -0,0 +1,370 @@ +package e2e + +import ( + "strings" + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestRpc(t *testing.T) { + cliPath := testharness.CLIPath() + if cliPath == "" { + t.Fatal("CLI not found. Run 'npm install' in the nodejs directory first.") + } + + 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), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + result, err := client.RPC.Ping(t.Context(), &rpc.PingParams{Message: copilot.String("typed rpc test")}) + if err != nil { + t.Fatalf("Failed to call RPC.Ping: %v", err) + } + + if result.Message != "pong: typed rpc test" { + t.Errorf("Expected message 'pong: typed rpc test', got %q", result.Message) + } + + if result.Timestamp < 0 { + t.Errorf("Expected timestamp >= 0, got %f", result.Timestamp) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + 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), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + authStatus, err := client.GetAuthStatus(t.Context()) + if err != nil { + t.Fatalf("Failed to get auth status: %v", err) + } + + if !authStatus.IsAuthenticated { + t.Skip("Not authenticated - skipping models.list test") + } + + result, err := client.RPC.Models.List(t.Context()) + if err != nil { + t.Fatalf("Failed to call RPC.Models.List: %v", err) + } + + if result.Models == nil { + t.Error("Expected models to be defined") + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + // account.getQuota is defined in schema but not yet implemented in CLI + t.Run("should call RPC.Account.GetQuota when authenticated", func(t *testing.T) { + t.Skip("account.getQuota not yet implemented in CLI") + + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + authStatus, err := client.GetAuthStatus(t.Context()) + if err != nil { + t.Fatalf("Failed to get auth status: %v", err) + } + + if !authStatus.IsAuthenticated { + t.Skip("Not authenticated - skipping account.getQuota test") + } + + result, err := client.RPC.Account.GetQuota(t.Context()) + if err != nil { + t.Fatalf("Failed to call RPC.Account.GetQuota: %v", err) + } + + if result.QuotaSnapshots == nil { + t.Error("Expected quotaSnapshots to be defined") + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) +} + +func TestSessionRpc(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + // session.model.getCurrent is defined in schema but not yet implemented in CLI + t.Run("should call session.RPC.Model.GetCurrent", func(t *testing.T) { + t.Skip("session.model.getCurrent not yet implemented in CLI") + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Model: "claude-sonnet-4.5", + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + result, err := session.RPC.Model.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to call session.RPC.Model.GetCurrent: %v", err) + } + + if result.ModelID == nil || *result.ModelID == "" { + t.Error("Expected modelId to be defined") + } + }) + + // session.model.switchTo is defined in schema but not yet implemented in CLI + t.Run("should call session.RPC.Model.SwitchTo", func(t *testing.T) { + t.Skip("session.model.switchTo not yet implemented in CLI") + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Model: "claude-sonnet-4.5", + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Get initial model + before, err := session.RPC.Model.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to get current model: %v", err) + } + if before.ModelID == nil || *before.ModelID == "" { + t.Error("Expected initial modelId to be defined") + } + + // Switch to a different model + result, err := session.RPC.Model.SwitchTo(t.Context(), &rpc.SessionModelSwitchToParams{ + ModelID: "gpt-4.1", + }) + if err != nil { + t.Fatalf("Failed to switch model: %v", err) + } + if result.ModelID == nil || *result.ModelID != "gpt-4.1" { + t.Errorf("Expected modelId 'gpt-4.1', got %v", result.ModelID) + } + + // Verify the switch persisted + after, err := session.RPC.Model.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to get current model after switch: %v", err) + } + if after.ModelID == nil || *after.ModelID != "gpt-4.1" { + t.Errorf("Expected modelId 'gpt-4.1' after switch, got %v", after.ModelID) + } + }) + + t.Run("should get and set session mode", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), nil) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Get initial mode (default should be interactive) + initial, err := session.RPC.Mode.Get(t.Context()) + if err != nil { + t.Fatalf("Failed to get mode: %v", err) + } + if initial.Mode != rpc.Interactive { + t.Errorf("Expected initial mode 'interactive', got %q", initial.Mode) + } + + // Switch to plan mode + planResult, err := session.RPC.Mode.Set(t.Context(), &rpc.SessionModeSetParams{Mode: rpc.Plan}) + if err != nil { + t.Fatalf("Failed to set mode to plan: %v", err) + } + if planResult.Mode != rpc.Plan { + t.Errorf("Expected mode 'plan', got %q", planResult.Mode) + } + + // Verify mode persisted + afterPlan, err := session.RPC.Mode.Get(t.Context()) + if err != nil { + t.Fatalf("Failed to get mode after plan: %v", err) + } + if afterPlan.Mode != rpc.Plan { + t.Errorf("Expected mode 'plan' after set, got %q", afterPlan.Mode) + } + + // Switch back to interactive + interactiveResult, err := session.RPC.Mode.Set(t.Context(), &rpc.SessionModeSetParams{Mode: rpc.Interactive}) + if err != nil { + t.Fatalf("Failed to set mode to interactive: %v", err) + } + if interactiveResult.Mode != rpc.Interactive { + t.Errorf("Expected mode 'interactive', got %q", interactiveResult.Mode) + } + }) + + t.Run("should read, update, and delete plan", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), nil) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Initially plan should not exist + initial, err := session.RPC.Plan.Read(t.Context()) + if err != nil { + t.Fatalf("Failed to read plan: %v", err) + } + if initial.Exists { + t.Error("Expected plan to not exist initially") + } + if initial.Content != nil { + t.Error("Expected content to be nil initially") + } + + // Create/update plan + planContent := "# Test Plan\n\n- Step 1\n- Step 2" + _, err = session.RPC.Plan.Update(t.Context(), &rpc.SessionPlanUpdateParams{Content: planContent}) + if err != nil { + t.Fatalf("Failed to update plan: %v", err) + } + + // Verify plan exists and has correct content + afterUpdate, err := session.RPC.Plan.Read(t.Context()) + if err != nil { + t.Fatalf("Failed to read plan after update: %v", err) + } + if !afterUpdate.Exists { + t.Error("Expected plan to exist after update") + } + if afterUpdate.Content == nil || *afterUpdate.Content != planContent { + t.Errorf("Expected content %q, got %v", planContent, afterUpdate.Content) + } + + // Delete plan + _, err = session.RPC.Plan.Delete(t.Context()) + if err != nil { + t.Fatalf("Failed to delete plan: %v", err) + } + + // Verify plan is deleted + afterDelete, err := session.RPC.Plan.Read(t.Context()) + if err != nil { + t.Fatalf("Failed to read plan after delete: %v", err) + } + if afterDelete.Exists { + t.Error("Expected plan to not exist after delete") + } + if afterDelete.Content != nil { + t.Error("Expected content to be nil after delete") + } + }) + + t.Run("should create, list, and read workspace files", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), nil) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Initially no files + initialFiles, err := session.RPC.Workspace.ListFiles(t.Context()) + if err != nil { + t.Fatalf("Failed to list files: %v", err) + } + if len(initialFiles.Files) != 0 { + t.Errorf("Expected no files initially, got %v", initialFiles.Files) + } + + // Create a file + fileContent := "Hello, workspace!" + _, err = session.RPC.Workspace.CreateFile(t.Context(), &rpc.SessionWorkspaceCreateFileParams{ + Path: "test.txt", + Content: fileContent, + }) + if err != nil { + t.Fatalf("Failed to create file: %v", err) + } + + // List files + afterCreate, err := session.RPC.Workspace.ListFiles(t.Context()) + if err != nil { + t.Fatalf("Failed to list files after create: %v", err) + } + if !containsString(afterCreate.Files, "test.txt") { + t.Errorf("Expected files to contain 'test.txt', got %v", afterCreate.Files) + } + + // Read file + readResult, err := session.RPC.Workspace.ReadFile(t.Context(), &rpc.SessionWorkspaceReadFileParams{ + Path: "test.txt", + }) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + if readResult.Content != fileContent { + t.Errorf("Expected content %q, got %q", fileContent, readResult.Content) + } + + // Create nested file + _, err = session.RPC.Workspace.CreateFile(t.Context(), &rpc.SessionWorkspaceCreateFileParams{ + Path: "subdir/nested.txt", + Content: "Nested content", + }) + if err != nil { + t.Fatalf("Failed to create nested file: %v", err) + } + + afterNested, err := session.RPC.Workspace.ListFiles(t.Context()) + if err != nil { + t.Fatalf("Failed to list files after nested: %v", err) + } + if !containsString(afterNested.Files, "test.txt") { + t.Errorf("Expected files to contain 'test.txt', got %v", afterNested.Files) + } + hasNested := false + for _, f := range afterNested.Files { + if strings.Contains(f, "nested.txt") { + hasNested = true + break + } + } + if !hasNested { + t.Errorf("Expected files to contain 'nested.txt', got %v", afterNested.Files) + } + }) +} + +func containsString(slice []string, str string) bool { + for _, s := range slice { + if s == str { + return true + } + } + return false +} diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index 621832862..87341838a 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -463,7 +463,9 @@ func TestSession(t *testing.T) { t.Run("should abort a session", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -775,7 +777,7 @@ func TestSession(t *testing.T) { time.Sleep(200 * time.Millisecond) // List sessions and verify they're included - sessions, err := client.ListSessions(t.Context()) + sessions, err := client.ListSessions(t.Context(), nil) if err != nil { t.Fatalf("Failed to list sessions: %v", err) } @@ -812,6 +814,15 @@ func TestSession(t *testing.T) { } // isRemote is a boolean, so it's always set } + + // Verify context field is present on sessions + for _, s := range sessions { + if s.Context != nil { + if s.Context.Cwd == "" { + t.Error("Expected context.Cwd to be non-empty when context is present") + } + } + } }) t.Run("should delete session", func(t *testing.T) { @@ -834,7 +845,7 @@ func TestSession(t *testing.T) { time.Sleep(200 * time.Millisecond) // Verify session exists in the list - sessions, err := client.ListSessions(t.Context()) + sessions, err := client.ListSessions(t.Context(), nil) if err != nil { t.Fatalf("Failed to list sessions: %v", err) } @@ -855,7 +866,7 @@ func TestSession(t *testing.T) { } // Verify session no longer exists in the list - sessionsAfter, err := client.ListSessions(t.Context()) + sessionsAfter, err := client.ListSessions(t.Context(), nil) if err != nil { t.Fatalf("Failed to list sessions after delete: %v", err) } diff --git a/go/internal/e2e/tools_test.go b/go/internal/e2e/tools_test.go index 5af9079ce..d54bdcb14 100644 --- a/go/internal/e2e/tools_test.go +++ b/go/internal/e2e/tools_test.go @@ -25,7 +25,9 @@ func TestTools(t *testing.T) { t.Fatalf("Failed to write test file: %v", err) } - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to create session: %v", err) } diff --git a/go/internal/embeddedcli/embeddedcli.go b/go/internal/embeddedcli/embeddedcli.go new file mode 100644 index 000000000..15c981d6e --- /dev/null +++ b/go/internal/embeddedcli/embeddedcli.go @@ -0,0 +1,202 @@ +package embeddedcli + +import ( + "bytes" + "crypto/sha256" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/github/copilot-sdk/go/internal/flock" +) + +// Config defines the inputs used to install and locate the embedded Copilot CLI. +// +// Cli and CliHash are required. If Dir is empty, the CLI is installed into the +// system cache directory. Version is used to suffix the installed binary name to +// allow multiple versions to coexist. License, when provided, is written next +// to the installed binary. +type Config struct { + Cli io.Reader + CliHash []byte + + License []byte + + Dir string + Version string +} + +func Setup(cfg Config) { + if cfg.Cli == nil { + panic("Cli reader is required") + } + if len(cfg.CliHash) != sha256.Size { + panic(fmt.Sprintf("CliHash must be a SHA-256 hash (%d bytes), got %d bytes", sha256.Size, len(cfg.CliHash))) + } + setupMu.Lock() + defer setupMu.Unlock() + if setupDone { + panic("Setup must only be called once") + } + if pathInitialized { + panic("Setup must be called before Path is accessed") + } + config = cfg + setupDone = true +} + +var Path = sync.OnceValue(func() string { + setupMu.Lock() + defer setupMu.Unlock() + if !setupDone { + return "" + } + pathInitialized = true + path := install() + return path +}) + +var ( + config Config + setupMu sync.Mutex + setupDone bool + pathInitialized bool +) + +func install() (path string) { + verbose := os.Getenv("COPILOT_CLI_INSTALL_VERBOSE") == "1" + logError := func(msg string, err error) { + if verbose { + fmt.Printf("embedded CLI installation error: %s: %v\n", msg, err) + } + } + if verbose { + start := time.Now() + defer func() { + duration := time.Since(start) + fmt.Printf("installing embedded CLI at %s installation took %s\n", path, duration) + }() + } + installDir := config.Dir + if installDir == "" { + var err error + if installDir, err = os.UserCacheDir(); err != nil { + // Fall back to temp dir if UserCacheDir is unavailable + installDir = os.TempDir() + } + installDir = filepath.Join(installDir, "copilot-sdk") + } + path, err := installAt(installDir) + if err != nil { + logError("installing in configured directory", err) + return "" + } + return path +} + +func installAt(installDir string) (string, error) { + if err := os.MkdirAll(installDir, 0755); err != nil { + return "", fmt.Errorf("creating install directory: %w", err) + } + version := sanitizeVersion(config.Version) + lockName := ".copilot-cli.lock" + if version != "" { + lockName = fmt.Sprintf(".copilot-cli-%s.lock", version) + } + + // Best effort to prevent concurrent installs. + if release, _ := flock.Acquire(filepath.Join(installDir, lockName)); release != nil { + defer release() + } + + binaryName := "copilot" + if runtime.GOOS == "windows" { + binaryName += ".exe" + } + finalPath := versionedBinaryPath(installDir, binaryName, version) + + if _, err := os.Stat(finalPath); err == nil { + existingHash, err := hashFile(finalPath) + if err != nil { + return "", fmt.Errorf("hashing existing binary: %w", err) + } + if !bytes.Equal(existingHash, config.CliHash) { + return "", fmt.Errorf("existing binary hash mismatch") + } + return finalPath, nil + } + + f, err := os.OpenFile(finalPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return "", fmt.Errorf("creating binary file: %w", err) + } + _, err = io.Copy(f, config.Cli) + if err1 := f.Close(); err1 != nil && err == nil { + err = err1 + } + if closer, ok := config.Cli.(io.Closer); ok { + closer.Close() + } + if err != nil { + return "", fmt.Errorf("writing binary file: %w", err) + } + if len(config.License) > 0 { + licensePath := finalPath + ".license" + if err := os.WriteFile(licensePath, config.License, 0644); err != nil { + return "", fmt.Errorf("writing license file: %w", err) + } + } + return finalPath, nil +} + +// versionedBinaryPath builds the unpacked binary filename with an optional version suffix. +func versionedBinaryPath(dir, binaryName, version string) string { + if version == "" { + return filepath.Join(dir, binaryName) + } + base := strings.TrimSuffix(binaryName, filepath.Ext(binaryName)) + ext := filepath.Ext(binaryName) + return filepath.Join(dir, fmt.Sprintf("%s_%s%s", base, version, ext)) +} + +// sanitizeVersion makes a version string safe for filenames. +func sanitizeVersion(version string) string { + if version == "" { + return "" + } + var b strings.Builder + for _, r := range version { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '.' || r == '-' || r == '_': + b.WriteRune(r) + default: + b.WriteRune('_') + } + } + return b.String() +} + +// hashFile returns the SHA-256 hash of a file on disk. +func hashFile(path string) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + h := sha256.New() + if _, err := io.Copy(h, file); err != nil { + return nil, err + } + return h.Sum(nil), nil +} diff --git a/go/internal/embeddedcli/embeddedcli_test.go b/go/internal/embeddedcli/embeddedcli_test.go new file mode 100644 index 000000000..0453f7293 --- /dev/null +++ b/go/internal/embeddedcli/embeddedcli_test.go @@ -0,0 +1,136 @@ +package embeddedcli + +import ( + "bytes" + "crypto/sha256" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func resetGlobals() { + setupMu.Lock() + defer setupMu.Unlock() + config = Config{} + setupDone = false + pathInitialized = false +} + +func mustPanic(t *testing.T, fn func()) { + t.Helper() + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic") + } + }() + fn() +} + +func binaryNameForOS() string { + name := "copilot" + if runtime.GOOS == "windows" { + name += ".exe" + } + return name +} + +func TestSetupPanicsOnNilCli(t *testing.T) { + resetGlobals() + mustPanic(t, func() { Setup(Config{}) }) +} + +func TestSetupPanicsOnSecondCall(t *testing.T) { + resetGlobals() + hash := sha256.Sum256([]byte("ok")) + Setup(Config{Cli: bytes.NewReader([]byte("ok")), CliHash: hash[:]}) + hash2 := sha256.Sum256([]byte("ok")) + mustPanic(t, func() { Setup(Config{Cli: bytes.NewReader([]byte("ok")), CliHash: hash2[:]}) }) + resetGlobals() +} + +func TestInstallAtWritesBinaryAndLicense(t *testing.T) { + resetGlobals() + tempDir := t.TempDir() + content := []byte("hello") + hash := sha256.Sum256(content) + Setup(Config{ + Cli: bytes.NewReader(content), + CliHash: hash[:], + License: []byte("license"), + Version: "1.2.3", + Dir: tempDir, + }) + + path := Path() + + expectedPath := versionedBinaryPath(tempDir, binaryNameForOS(), "1.2.3") + if path != expectedPath { + t.Fatalf("unexpected path: got %q want %q", path, expectedPath) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read binary: %v", err) + } + if !bytes.Equal(got, content) { + t.Fatalf("binary content mismatch") + } + + licensePath := path + ".license" + license, err := os.ReadFile(licensePath) + if err != nil { + t.Fatalf("read license: %v", err) + } + if string(license) != "license" { + t.Fatalf("license content mismatch") + } + + gotHash, err := hashFile(path) + if err != nil { + t.Fatalf("hash file: %v", err) + } + if !bytes.Equal(gotHash, hash[:]) { + t.Fatalf("hash mismatch") + } +} + +func TestInstallAtExistingBinaryHashMismatch(t *testing.T) { + resetGlobals() + tempDir := t.TempDir() + binaryPath := versionedBinaryPath(tempDir, binaryNameForOS(), "") + if err := os.MkdirAll(filepath.Dir(binaryPath), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(binaryPath, []byte("bad"), 0755); err != nil { + t.Fatalf("write binary: %v", err) + } + + goodHash := sha256.Sum256([]byte("good")) + config = Config{ + Cli: bytes.NewReader([]byte("good")), + CliHash: goodHash[:], + } + + _, err := installAt(tempDir) + if err == nil || !strings.Contains(err.Error(), "hash mismatch") { + t.Fatalf("expected hash mismatch error, got %v", err) + } +} + +func TestSanitizeVersion(t *testing.T) { + got := sanitizeVersion("v1.2.3+build/abc") + want := "v1.2.3_build_abc" + if got != want { + t.Fatalf("sanitizeVersion() = %q want %q", got, want) + } +} + +func TestVersionedBinaryPath(t *testing.T) { + got := versionedBinaryPath("/tmp", "copilot.exe", "1.0.0") + want := filepath.Join("/tmp", "copilot_1.0.0.exe") + if got != want { + t.Fatalf("versionedBinaryPath() = %q want %q", got, want) + } +} diff --git a/go/internal/flock/flock.go b/go/internal/flock/flock.go new file mode 100644 index 000000000..fbf985a35 --- /dev/null +++ b/go/internal/flock/flock.go @@ -0,0 +1,29 @@ +package flock + +import "os" + +// Acquire opens (or creates) the lock file at path and blocks until the lock is acquired. +// It returns a release function to unlock and close the file. +func Acquire(path string) (func() error, error) { + f, err := os.OpenFile(path, os.O_CREATE, 0644) + if err != nil { + return nil, err + } + if err := lockFile(f); err != nil { + _ = f.Close() + return nil, err + } + released := false + release := func() error { + if released { + return nil + } + released = true + err := unlockFile(f) + if err1 := f.Close(); err == nil { + err = err1 + } + return err + } + return release, nil +} diff --git a/go/internal/flock/flock_other.go b/go/internal/flock/flock_other.go new file mode 100644 index 000000000..833b34600 --- /dev/null +++ b/go/internal/flock/flock_other.go @@ -0,0 +1,16 @@ +//go:build !windows && (!unix || aix || (solaris && !illumos)) + +package flock + +import ( + "errors" + "os" +) + +func lockFile(_ *os.File) error { + return errors.ErrUnsupported +} + +func unlockFile(_ *os.File) (err error) { + return errors.ErrUnsupported +} diff --git a/go/internal/flock/flock_test.go b/go/internal/flock/flock_test.go new file mode 100644 index 000000000..de26f6619 --- /dev/null +++ b/go/internal/flock/flock_test.go @@ -0,0 +1,88 @@ +package flock + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestAcquireReleaseCreatesFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "lockfile") + + release, err := Acquire(path) + if errors.Is(err, errors.ErrUnsupported) { + t.Skip("file locking unsupported on this platform") + } + if err != nil { + t.Fatalf("Acquire failed: %v", err) + } + if _, err := os.Stat(path); err != nil { + release() + t.Fatalf("lock file not created: %v", err) + } + + if err := release(); err != nil { + t.Fatalf("Release failed: %v", err) + } + if err := release(); err != nil { + t.Fatalf("Release should be idempotent: %v", err) + } +} + +func TestLockBlocksUntilRelease(t *testing.T) { + path := filepath.Join(t.TempDir(), "lockfile") + + first, err := Acquire(path) + if errors.Is(err, errors.ErrUnsupported) { + t.Skip("file locking unsupported on this platform") + } + if err != nil { + t.Fatalf("Acquire failed: %v", err) + } + defer first() + + result := make(chan error, 1) + var second func() error + go func() { + lock, err := Acquire(path) + if err == nil { + second = lock + } + result <- err + }() + + blockCtx, cancelBlock := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancelBlock() + select { + case err := <-result: + if err == nil && second != nil { + _ = second() + } + t.Fatalf("second Acquire should block, returned early: %v", err) + case <-blockCtx.Done(): + } + + if err := first(); err != nil { + t.Fatalf("Release failed: %v", err) + } + + unlockCtx, cancelUnlock := context.WithTimeout(t.Context(), 1*time.Second) + defer cancelUnlock() + select { + case err := <-result: + if err != nil { + t.Fatalf("second Acquire failed: %v", err) + } + if second == nil { + t.Fatalf("second lock was not set") + } + if err := second(); err != nil { + t.Fatalf("second Release failed: %v", err) + } + case <-unlockCtx.Done(): + t.Fatalf("second Acquire did not unblock") + } +} diff --git a/go/internal/flock/flock_unix.go b/go/internal/flock/flock_unix.go new file mode 100644 index 000000000..dbfc0a1f5 --- /dev/null +++ b/go/internal/flock/flock_unix.go @@ -0,0 +1,28 @@ +//go:build darwin || dragonfly || freebsd || illumos || linux || netbsd || openbsd + +package flock + +import ( + "os" + "syscall" +) + +func lockFile(f *os.File) (err error) { + for { + err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX) + if err != syscall.EINTR { + break + } + } + return err +} + +func unlockFile(f *os.File) (err error) { + for { + err = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + if err != syscall.EINTR { + break + } + } + return err +} diff --git a/go/internal/flock/flock_windows.go b/go/internal/flock/flock_windows.go new file mode 100644 index 000000000..fc3322a15 --- /dev/null +++ b/go/internal/flock/flock_windows.go @@ -0,0 +1,66 @@ +//go:build windows + +package flock + +import ( + "os" + "syscall" + "unsafe" +) + +var ( + modKernel32 = syscall.NewLazyDLL("kernel32.dll") + procLockFileEx = modKernel32.NewProc("LockFileEx") + procUnlockFileEx = modKernel32.NewProc("UnlockFileEx") +) + +const LOCKFILE_EXCLUSIVE_LOCK = 0x00000002 + +func lockFile(f *os.File) error { + rc, err := f.SyscallConn() + if err != nil { + return err + } + var callErr error + if err := rc.Control(func(fd uintptr) { + var ol syscall.Overlapped + r1, _, e1 := procLockFileEx.Call( + fd, + uintptr(LOCKFILE_EXCLUSIVE_LOCK), + 0, + 1, + 0, + uintptr(unsafe.Pointer(&ol)), + ) + if r1 == 0 { + callErr = e1 + } + }); err != nil { + return err + } + return callErr +} + +func unlockFile(f *os.File) error { + rc, err := f.SyscallConn() + if err != nil { + return err + } + var callErr error + if err := rc.Control(func(fd uintptr) { + var ol syscall.Overlapped + r1, _, e1 := procUnlockFileEx.Call( + fd, + 0, + 1, + 0, + uintptr(unsafe.Pointer(&ol)), + ) + if r1 == 0 { + callErr = e1 + } + }); err != nil { + return err + } + return callErr +} diff --git a/go/internal/jsonrpc2/jsonrpc2.go b/go/internal/jsonrpc2/jsonrpc2.go index e44e12315..03cf49b3c 100644 --- a/go/internal/jsonrpc2/jsonrpc2.go +++ b/go/internal/jsonrpc2/jsonrpc2.go @@ -57,6 +57,9 @@ type Client struct { running bool stopChan chan struct{} wg sync.WaitGroup + processDone chan struct{} // closed when the underlying process exits + processError error // set before processDone is closed + processErrorMu sync.RWMutex // protects processError } // NewClient creates a new JSON-RPC client @@ -70,6 +73,28 @@ func NewClient(stdin io.WriteCloser, stdout io.ReadCloser) *Client { } } +// SetProcessDone sets a channel that will be closed when the process exits, +// and stores the error that should be returned to pending/future requests. +func (c *Client) SetProcessDone(done chan struct{}, errPtr *error) { + c.processDone = done + // Monitor the channel and copy the error when it closes + go func() { + <-done + if errPtr != nil { + c.processErrorMu.Lock() + c.processError = *errPtr + c.processErrorMu.Unlock() + } + }() +} + +// getProcessError returns the process exit error if the process has exited +func (c *Client) getProcessError() error { + c.processErrorMu.RLock() + defer c.processErrorMu.RUnlock() + return c.processError +} + // Start begins listening for messages in a background goroutine func (c *Client) Start() { c.running = true @@ -172,6 +197,19 @@ func (c *Client) Request(method string, params any) (json.RawMessage, error) { c.mu.Unlock() }() + // Check if process already exited before sending + if c.processDone != nil { + select { + case <-c.processDone: + if err := c.getProcessError(); err != nil { + return nil, err + } + return nil, fmt.Errorf("process exited unexpectedly") + default: + // Process still running, continue + } + } + paramsData, err := json.Marshal(params) if err != nil { return nil, fmt.Errorf("failed to marshal params: %w", err) @@ -189,7 +227,23 @@ func (c *Client) Request(method string, params any) (json.RawMessage, error) { return nil, fmt.Errorf("failed to send request: %w", err) } - // Wait for response + // Wait for response, also checking for process exit + if c.processDone != nil { + select { + case response := <-responseChan: + if response.Error != nil { + return nil, response.Error + } + return response.Result, nil + case <-c.processDone: + if err := c.getProcessError(); err != nil { + return nil, err + } + return nil, fmt.Errorf("process exited unexpectedly") + case <-c.stopChan: + return nil, fmt.Errorf("client stopped") + } + } select { case response := <-responseChan: if response.Error != nil { diff --git a/go/permissions.go b/go/permissions.go new file mode 100644 index 000000000..91ff776cf --- /dev/null +++ b/go/permissions.go @@ -0,0 +1,11 @@ +package copilot + +// PermissionHandler provides pre-built OnPermissionRequest implementations. +var PermissionHandler = struct { + // ApproveAll approves all permission requests. + ApproveAll PermissionHandlerFunc +}{ + ApproveAll: func(_ PermissionRequest, _ PermissionInvocation) (PermissionRequestResult, error) { + return PermissionRequestResult{Kind: "approved"}, nil + }, +} diff --git a/go/process_other.go b/go/process_other.go new file mode 100644 index 000000000..5b3ba6353 --- /dev/null +++ b/go/process_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package copilot + +import "os/exec" + +// configureProcAttr configures platform-specific process attributes. +// On non-Windows platforms, this is a no-op. +func configureProcAttr(cmd *exec.Cmd) { + // No special configuration needed on non-Windows platforms +} diff --git a/go/process_windows.go b/go/process_windows.go new file mode 100644 index 000000000..37f954fca --- /dev/null +++ b/go/process_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package copilot + +import ( + "os/exec" + "syscall" +) + +// configureProcAttr configures platform-specific process attributes. +// On Windows, this hides the console window to avoid distracting users in GUI apps. +func configureProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + } +} diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go new file mode 100644 index 000000000..c7d9b0c07 --- /dev/null +++ b/go/rpc/generated_rpc.go @@ -0,0 +1,494 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +package rpc + +import ( + "context" + "encoding/json" + + "github.com/github/copilot-sdk/go/internal/jsonrpc2" +) + +type PingResult struct { + // Echoed message (or default greeting) + Message string `json:"message"` + // Server protocol version number + ProtocolVersion float64 `json:"protocolVersion"` + // Server timestamp in milliseconds + Timestamp float64 `json:"timestamp"` +} + +type PingParams struct { + // Optional message to echo back + Message *string `json:"message,omitempty"` +} + +type ModelsListResult struct { + // List of available models with full metadata + Models []Model `json:"models"` +} + +type Model struct { + // Billing information + Billing *Billing `json:"billing,omitempty"` + // Model capabilities and limits + Capabilities Capabilities `json:"capabilities"` + // Default reasoning effort level (only present if model supports reasoning effort) + DefaultReasoningEffort *string `json:"defaultReasoningEffort,omitempty"` + // Model identifier (e.g., "claude-sonnet-4.5") + ID string `json:"id"` + // Display name + Name string `json:"name"` + // Policy state (if applicable) + Policy *Policy `json:"policy,omitempty"` + // Supported reasoning effort levels (only present if model supports reasoning effort) + SupportedReasoningEfforts []string `json:"supportedReasoningEfforts,omitempty"` +} + +// Billing information +type Billing struct { + Multiplier float64 `json:"multiplier"` +} + +// Model capabilities and limits +type Capabilities struct { + Limits Limits `json:"limits"` + Supports Supports `json:"supports"` +} + +type Limits struct { + MaxContextWindowTokens float64 `json:"max_context_window_tokens"` + MaxOutputTokens *float64 `json:"max_output_tokens,omitempty"` + MaxPromptTokens *float64 `json:"max_prompt_tokens,omitempty"` +} + +type Supports struct { + // Whether this model supports reasoning effort configuration + ReasoningEffort bool `json:"reasoningEffort"` + Vision bool `json:"vision"` +} + +// Policy state (if applicable) +type Policy struct { + State string `json:"state"` + Terms string `json:"terms"` +} + +type ToolsListResult struct { + // List of available built-in tools with metadata + Tools []Tool `json:"tools"` +} + +type Tool struct { + // Description of what the tool does + Description string `json:"description"` + // Optional instructions for how to use this tool effectively + Instructions *string `json:"instructions,omitempty"` + // Tool identifier (e.g., "bash", "grep", "str_replace_editor") + Name string `json:"name"` + // Optional namespaced name for declarative filtering (e.g., "playwright/navigate" for MCP + // tools) + NamespacedName *string `json:"namespacedName,omitempty"` + // JSON Schema for the tool's input parameters + Parameters map[string]interface{} `json:"parameters,omitempty"` +} + +type ToolsListParams struct { + // Optional model ID — when provided, the returned tool list reflects model-specific + // overrides + Model *string `json:"model,omitempty"` +} + +type AccountGetQuotaResult struct { + // Quota snapshots keyed by type (e.g., chat, completions, premium_interactions) + QuotaSnapshots map[string]QuotaSnapshot `json:"quotaSnapshots"` +} + +type QuotaSnapshot struct { + // Number of requests included in the entitlement + EntitlementRequests float64 `json:"entitlementRequests"` + // Number of overage requests made this period + Overage float64 `json:"overage"` + // Whether pay-per-request usage is allowed when quota is exhausted + OverageAllowedWithExhaustedQuota bool `json:"overageAllowedWithExhaustedQuota"` + // Percentage of entitlement remaining + RemainingPercentage float64 `json:"remainingPercentage"` + // Date when the quota resets (ISO 8601) + ResetDate *string `json:"resetDate,omitempty"` + // Number of requests used so far this period + UsedRequests float64 `json:"usedRequests"` +} + +type SessionModelGetCurrentResult struct { + ModelID *string `json:"modelId,omitempty"` +} + +type SessionModelSwitchToResult struct { + ModelID *string `json:"modelId,omitempty"` +} + +type SessionModelSwitchToParams struct { + ModelID string `json:"modelId"` +} + +type SessionModeGetResult struct { + // The current agent mode. + Mode Mode `json:"mode"` +} + +type SessionModeSetResult struct { + // The agent mode after switching. + Mode Mode `json:"mode"` +} + +type SessionModeSetParams struct { + // The mode to switch to. Valid values: "interactive", "plan", "autopilot". + Mode Mode `json:"mode"` +} + +type SessionPlanReadResult struct { + // The content of plan.md, or null if it does not exist + Content *string `json:"content"` + // Whether plan.md exists in the workspace + Exists bool `json:"exists"` +} + +type SessionPlanUpdateResult struct { +} + +type SessionPlanUpdateParams struct { + // The new content for plan.md + Content string `json:"content"` +} + +type SessionPlanDeleteResult struct { +} + +type SessionWorkspaceListFilesResult struct { + // Relative file paths in the workspace files directory + Files []string `json:"files"` +} + +type SessionWorkspaceReadFileResult struct { + // File content as a UTF-8 string + Content string `json:"content"` +} + +type SessionWorkspaceReadFileParams struct { + // Relative path within the workspace files directory + Path string `json:"path"` +} + +type SessionWorkspaceCreateFileResult struct { +} + +type SessionWorkspaceCreateFileParams struct { + // File content to write as a UTF-8 string + Content string `json:"content"` + // Relative path within the workspace files directory + Path string `json:"path"` +} + +type SessionFleetStartResult struct { + // Whether fleet mode was successfully activated + Started bool `json:"started"` +} + +type SessionFleetStartParams struct { + // Optional user prompt to combine with fleet instructions + Prompt *string `json:"prompt,omitempty"` +} + +// The current agent mode. +// +// The agent mode after switching. +// +// The mode to switch to. Valid values: "interactive", "plan", "autopilot". +type Mode string + +const ( + Autopilot Mode = "autopilot" + Interactive Mode = "interactive" + Plan Mode = "plan" +) + +type ModelsRpcApi struct{ client *jsonrpc2.Client } + +func (a *ModelsRpcApi) List(ctx context.Context) (*ModelsListResult, error) { + raw, err := a.client.Request("models.list", map[string]interface{}{}) + if err != nil { + return nil, err + } + var result ModelsListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type ToolsRpcApi struct{ client *jsonrpc2.Client } + +func (a *ToolsRpcApi) List(ctx context.Context, params *ToolsListParams) (*ToolsListResult, error) { + raw, err := a.client.Request("tools.list", params) + if err != nil { + return nil, err + } + var result ToolsListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type AccountRpcApi struct{ client *jsonrpc2.Client } + +func (a *AccountRpcApi) GetQuota(ctx context.Context) (*AccountGetQuotaResult, error) { + raw, err := a.client.Request("account.getQuota", map[string]interface{}{}) + if err != nil { + return nil, err + } + var result AccountGetQuotaResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ServerRpc provides typed server-scoped RPC methods. +type ServerRpc struct { + client *jsonrpc2.Client + Models *ModelsRpcApi + Tools *ToolsRpcApi + Account *AccountRpcApi +} + +func (a *ServerRpc) Ping(ctx context.Context, params *PingParams) (*PingResult, error) { + raw, err := a.client.Request("ping", params) + if err != nil { + return nil, err + } + var result PingResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func NewServerRpc(client *jsonrpc2.Client) *ServerRpc { + return &ServerRpc{client: client, + Models: &ModelsRpcApi{client: client}, + Tools: &ToolsRpcApi{client: client}, + Account: &AccountRpcApi{client: client}, + } +} + +type ModelRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *ModelRpcApi) GetCurrent(ctx context.Context) (*SessionModelGetCurrentResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.model.getCurrent", req) + if err != nil { + return nil, err + } + var result SessionModelGetCurrentResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *ModelRpcApi) SwitchTo(ctx context.Context, params *SessionModelSwitchToParams) (*SessionModelSwitchToResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["modelId"] = params.ModelID + } + raw, err := a.client.Request("session.model.switchTo", req) + if err != nil { + return nil, err + } + var result SessionModelSwitchToResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type ModeRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *ModeRpcApi) Get(ctx context.Context) (*SessionModeGetResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.mode.get", req) + if err != nil { + return nil, err + } + var result SessionModeGetResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *ModeRpcApi) Set(ctx context.Context, params *SessionModeSetParams) (*SessionModeSetResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["mode"] = params.Mode + } + raw, err := a.client.Request("session.mode.set", req) + if err != nil { + return nil, err + } + var result SessionModeSetResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type PlanRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *PlanRpcApi) Read(ctx context.Context) (*SessionPlanReadResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.plan.read", req) + if err != nil { + return nil, err + } + var result SessionPlanReadResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *PlanRpcApi) Update(ctx context.Context, params *SessionPlanUpdateParams) (*SessionPlanUpdateResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["content"] = params.Content + } + raw, err := a.client.Request("session.plan.update", req) + if err != nil { + return nil, err + } + var result SessionPlanUpdateResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *PlanRpcApi) Delete(ctx context.Context) (*SessionPlanDeleteResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.plan.delete", req) + if err != nil { + return nil, err + } + var result SessionPlanDeleteResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type WorkspaceRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *WorkspaceRpcApi) ListFiles(ctx context.Context) (*SessionWorkspaceListFilesResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.workspace.listFiles", req) + if err != nil { + return nil, err + } + var result SessionWorkspaceListFilesResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *WorkspaceRpcApi) ReadFile(ctx context.Context, params *SessionWorkspaceReadFileParams) (*SessionWorkspaceReadFileResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["path"] = params.Path + } + raw, err := a.client.Request("session.workspace.readFile", req) + if err != nil { + return nil, err + } + var result SessionWorkspaceReadFileResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *WorkspaceRpcApi) CreateFile(ctx context.Context, params *SessionWorkspaceCreateFileParams) (*SessionWorkspaceCreateFileResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["path"] = params.Path + req["content"] = params.Content + } + raw, err := a.client.Request("session.workspace.createFile", req) + if err != nil { + return nil, err + } + var result SessionWorkspaceCreateFileResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type FleetRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *FleetRpcApi) Start(ctx context.Context, params *SessionFleetStartParams) (*SessionFleetStartResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + if params.Prompt != nil { + req["prompt"] = *params.Prompt + } + } + raw, err := a.client.Request("session.fleet.start", req) + if err != nil { + return nil, err + } + var result SessionFleetStartResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// SessionRpc provides typed session-scoped RPC methods. +type SessionRpc struct { + client *jsonrpc2.Client + sessionID string + Model *ModelRpcApi + Mode *ModeRpcApi + Plan *PlanRpcApi + Workspace *WorkspaceRpcApi + Fleet *FleetRpcApi +} + +func NewSessionRpc(client *jsonrpc2.Client, sessionID string) *SessionRpc { + return &SessionRpc{client: client, sessionID: sessionID, + Model: &ModelRpcApi{client: client, sessionID: sessionID}, + Mode: &ModeRpcApi{client: client, sessionID: sessionID}, + Plan: &PlanRpcApi{client: client, sessionID: sessionID}, + Workspace: &WorkspaceRpcApi{client: client, sessionID: sessionID}, + Fleet: &FleetRpcApi{client: client, sessionID: sessionID}, + } +} diff --git a/go/samples/chat.go b/go/samples/chat.go new file mode 100644 index 000000000..4fc11ffda --- /dev/null +++ b/go/samples/chat.go @@ -0,0 +1,73 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/github/copilot-sdk/go" +) + +const blue = "\033[34m" +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}) + if err := client.Start(ctx); err != nil { + panic(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + CLIPath: cliPath, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + panic(err) + } + defer session.Destroy() + + session.On(func(event copilot.SessionEvent) { + var output string + switch event.Type { + case copilot.AssistantReasoning: + if event.Data.Content != nil { + output = fmt.Sprintf("[reasoning: %s]", *event.Data.Content) + } + case copilot.ToolExecutionStart: + if event.Data.ToolName != nil { + output = fmt.Sprintf("[tool: %s]", *event.Data.ToolName) + } + } + if output != "" { + fmt.Printf("%s%s%s\n", blue, output, reset) + } + }) + + fmt.Println("Chat with Copilot (Ctrl+C to exit)\n") + scanner := bufio.NewScanner(os.Stdin) + + for { + fmt.Print("You: ") + if !scanner.Scan() { + break + } + input := strings.TrimSpace(scanner.Text()) + if input == "" { + continue + } + fmt.Println() + + reply, _ := session.SendAndWait(ctx, copilot.MessageOptions{Prompt: input}) + content := "" + if reply != nil && reply.Data.Content != nil { + content = *reply.Data.Content + } + fmt.Printf("\nAssistant: %s\n\n", content) + } +} diff --git a/go/samples/go.mod b/go/samples/go.mod new file mode 100644 index 000000000..889070f67 --- /dev/null +++ b/go/samples/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/go/samples + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../ diff --git a/go/samples/go.sum b/go/samples/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/go/samples/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/go/session.go b/go/session.go index 37cfe52f8..12d1b1afa 100644 --- a/go/session.go +++ b/go/session.go @@ -9,6 +9,7 @@ import ( "time" "github.com/github/copilot-sdk/go/internal/jsonrpc2" + "github.com/github/copilot-sdk/go/rpc" ) type sessionHandler struct { @@ -57,12 +58,15 @@ type Session struct { handlerMutex sync.RWMutex toolHandlers map[string]ToolHandler toolHandlersM sync.RWMutex - permissionHandler PermissionHandler + permissionHandler PermissionHandlerFunc permissionMux sync.RWMutex userInputHandler UserInputHandler userInputMux sync.RWMutex hooks *SessionHooks hooksMux sync.RWMutex + + // RPC provides typed session-scoped RPC methods. + RPC *rpc.SessionRpc } // WorkspacePath returns the path to the session workspace directory when infinite @@ -80,6 +84,7 @@ func newSession(sessionID string, client *jsonrpc2.Client, workspacePath string) client: client, handlers: make([]sessionHandler, 0), toolHandlers: make(map[string]ToolHandler), + RPC: rpc.NewSessionRpc(client, sessionID), } } @@ -285,14 +290,14 @@ func (s *Session) getToolHandler(name string) (ToolHandler, bool) { // operations), this handler is called to approve or deny the request. // // This method is internal and typically called when creating a session. -func (s *Session) registerPermissionHandler(handler PermissionHandler) { +func (s *Session) registerPermissionHandler(handler PermissionHandlerFunc) { s.permissionMux.Lock() defer s.permissionMux.Unlock() s.permissionHandler = handler } // getPermissionHandler returns the currently registered permission handler, or nil. -func (s *Session) getPermissionHandler() PermissionHandler { +func (s *Session) getPermissionHandler() PermissionHandlerFunc { s.permissionMux.RLock() defer s.permissionMux.RUnlock() return s.permissionHandler diff --git a/go/types.go b/go/types.go index a3b38ee31..6abbf4a12 100644 --- a/go/types.go +++ b/go/types.go @@ -16,6 +16,8 @@ const ( 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) Cwd string // Port for TCP transport (default: 0 = random port) @@ -60,6 +62,12 @@ func Bool(v bool) *bool { return &v } +// String returns a pointer to the given string value. +// Use for setting optional string parameters in RPC calls. +func String(v string) *string { + return &v +} + // Float64 returns a pointer to the given float64 value. // Use for setting thresholds: BackgroundCompactionThreshold: Float64(0.80) func Float64(v float64) *float64 { @@ -104,9 +112,9 @@ type PermissionRequestResult struct { Rules []any `json:"rules,omitempty"` } -// PermissionHandler executes a permission request +// PermissionHandlerFunc executes a permission request // The handler should return a PermissionRequestResult. Returning an error denies the permission. -type PermissionHandler func(request PermissionRequest, invocation PermissionInvocation) (PermissionRequestResult, error) +type PermissionHandlerFunc func(request PermissionRequest, invocation PermissionInvocation) (PermissionRequestResult, error) // PermissionInvocation provides context about a permission request type PermissionInvocation struct { @@ -322,6 +330,9 @@ type InfiniteSessionConfig struct { type SessionConfig struct { // SessionID is an optional custom session ID SessionID string + // ClientName identifies the application using the SDK. + // Included in the User-Agent header for API requests. + ClientName string // Model to use for this session Model string // ReasoningEffort level for models that support it. @@ -341,8 +352,10 @@ type SessionConfig struct { // ExcludedTools is a list of tool names to disable. All other tools remain available. // Ignored if AvailableTools is specified. ExcludedTools []string - // OnPermissionRequest is a handler for permission requests from the server - OnPermissionRequest PermissionHandler + // OnPermissionRequest is a handler for permission requests from the server. + // If nil, all permission requests are denied by default. + // Provide a handler to approve operations (file writes, shell commands, URL fetches, etc.). + OnPermissionRequest PermissionHandlerFunc // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) OnUserInputRequest UserInputHandler // Hooks configures hook handlers for session lifecycle events @@ -401,6 +414,9 @@ type ToolResult struct { // ResumeSessionConfig configures options when resuming a session type ResumeSessionConfig struct { + // ClientName identifies the application using the SDK. + // Included in the User-Agent header for API requests. + ClientName string // Model to use for this session. Can change the model when resuming. Model string // Tools exposes caller-implemented tools to the CLI @@ -418,8 +434,10 @@ type ResumeSessionConfig struct { // ReasoningEffort level for models that support it. // Valid values: "low", "medium", "high", "xhigh" ReasoningEffort string - // OnPermissionRequest is a handler for permission requests from the server - OnPermissionRequest PermissionHandler + // OnPermissionRequest is a handler for permission requests from the server. + // If nil, all permission requests are denied by default. + // Provide a handler to approve operations (file writes, shell commands, URL fetches, etc.). + OnPermissionRequest PermissionHandlerFunc // OnUserInputRequest is a handler for user input requests from the agent (enables ask_user tool) OnUserInputRequest UserInputHandler // Hooks configures hook handlers for session lifecycle events @@ -541,13 +559,38 @@ type ModelInfo struct { DefaultReasoningEffort string `json:"defaultReasoningEffort,omitempty"` } +// SessionContext contains working directory context for a session +type SessionContext struct { + // Cwd is the working directory where the session was created + Cwd string `json:"cwd"` + // GitRoot is the git repository root (if in a git repo) + GitRoot string `json:"gitRoot,omitempty"` + // Repository is the GitHub repository in "owner/repo" format + Repository string `json:"repository,omitempty"` + // Branch is the current git branch + Branch string `json:"branch,omitempty"` +} + +// SessionListFilter contains filter options for listing sessions +type SessionListFilter struct { + // Cwd filters by exact working directory match + Cwd string `json:"cwd,omitempty"` + // GitRoot filters by git root + GitRoot string `json:"gitRoot,omitempty"` + // Repository filters by repository (owner/repo format) + Repository string `json:"repository,omitempty"` + // Branch filters by branch + Branch string `json:"branch,omitempty"` +} + // SessionMetadata contains metadata about a session type SessionMetadata struct { - SessionID string `json:"sessionId"` - StartTime string `json:"startTime"` - ModifiedTime string `json:"modifiedTime"` - Summary *string `json:"summary,omitempty"` - IsRemote bool `json:"isRemote"` + SessionID string `json:"sessionId"` + StartTime string `json:"startTime"` + ModifiedTime string `json:"modifiedTime"` + Summary *string `json:"summary,omitempty"` + IsRemote bool `json:"isRemote"` + Context *SessionContext `json:"context,omitempty"` } // SessionLifecycleEventType represents the type of session lifecycle event @@ -593,10 +636,11 @@ type permissionRequestResponse struct { type createSessionRequest struct { Model string `json:"model,omitempty"` SessionID string `json:"sessionId,omitempty"` + ClientName string `json:"clientName,omitempty"` ReasoningEffort string `json:"reasoningEffort,omitempty"` Tools []Tool `json:"tools,omitempty"` SystemMessage *SystemMessageConfig `json:"systemMessage,omitempty"` - AvailableTools []string `json:"availableTools,omitempty"` + AvailableTools []string `json:"availableTools"` ExcludedTools []string `json:"excludedTools,omitempty"` Provider *ProviderConfig `json:"provider,omitempty"` RequestPermission *bool `json:"requestPermission,omitempty"` @@ -605,6 +649,7 @@ type createSessionRequest struct { WorkingDirectory string `json:"workingDirectory,omitempty"` Streaming *bool `json:"streaming,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` + EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` ConfigDir string `json:"configDir,omitempty"` SkillDirectories []string `json:"skillDirectories,omitempty"` @@ -621,11 +666,12 @@ type createSessionResponse struct { // resumeSessionRequest is the request for session.resume type resumeSessionRequest struct { SessionID string `json:"sessionId"` + ClientName string `json:"clientName,omitempty"` Model string `json:"model,omitempty"` ReasoningEffort string `json:"reasoningEffort,omitempty"` Tools []Tool `json:"tools,omitempty"` SystemMessage *SystemMessageConfig `json:"systemMessage,omitempty"` - AvailableTools []string `json:"availableTools,omitempty"` + AvailableTools []string `json:"availableTools"` ExcludedTools []string `json:"excludedTools,omitempty"` Provider *ProviderConfig `json:"provider,omitempty"` RequestPermission *bool `json:"requestPermission,omitempty"` @@ -636,6 +682,7 @@ type resumeSessionRequest struct { DisableResume *bool `json:"disableResume,omitempty"` Streaming *bool `json:"streaming,omitempty"` MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` + EnvValueMode string `json:"envValueMode,omitempty"` CustomAgents []CustomAgentConfig `json:"customAgents,omitempty"` SkillDirectories []string `json:"skillDirectories,omitempty"` DisabledSkills []string `json:"disabledSkills,omitempty"` @@ -655,7 +702,9 @@ type hooksInvokeRequest struct { } // listSessionsRequest is the request for session.list -type listSessionsRequest struct{} +type listSessionsRequest struct { + Filter *SessionListFilter `json:"filter,omitempty"` +} // listSessionsResponse is the response from session.list type listSessionsResponse struct { diff --git a/justfile b/justfile index 8cf72c732..5eea5100f 100644 --- a/justfile +++ b/justfile @@ -117,3 +117,112 @@ validate-docs-go: validate-docs-cs: @echo "=== Validating C# documentation ===" @cd scripts/docs-validation && npm run validate:cs + +# Build all scenario samples (all languages) +scenario-build: + #!/usr/bin/env bash + set -euo pipefail + echo "=== Building all scenario samples ===" + TOTAL=0; PASS=0; FAIL=0 + + build_lang() { + local lang="$1" find_expr="$2" build_cmd="$3" + echo "" + echo "── $lang ──" + while IFS= read -r target; do + [ -z "$target" ] && continue + dir=$(dirname "$target") + scenario="${dir#test/scenarios/}" + TOTAL=$((TOTAL + 1)) + if (cd "$dir" && eval "$build_cmd" >/dev/null 2>&1); then + printf " ✅ %s\n" "$scenario" + PASS=$((PASS + 1)) + else + printf " ❌ %s\n" "$scenario" + FAIL=$((FAIL + 1)) + fi + done < <(find test/scenarios $find_expr | sort) + } + + # TypeScript: npm install + (cd nodejs && npm ci --ignore-scripts --silent 2>/dev/null) || true + build_lang "TypeScript" "-path '*/typescript/package.json'" "npm install --ignore-scripts" + + # Python: syntax check + build_lang "Python" "-path '*/python/main.py'" "python3 -c \"import ast; ast.parse(open('main.py').read())\"" + + # Go: go build + build_lang "Go" "-path '*/go/go.mod'" "go build ./..." + + # C#: dotnet build + build_lang "C#" "-name '*.csproj' -path '*/csharp/*'" "dotnet build --nologo -v quiet" + + echo "" + echo "══════════════════════════════════════" + echo " Scenario build summary: $PASS passed, $FAIL failed (of $TOTAL)" + echo "══════════════════════════════════════" + [ "$FAIL" -eq 0 ] + +# Run the full scenario verify orchestrator (build + E2E, needs real CLI) +scenario-verify: + @echo "=== Running scenario verification ===" + @bash test/scenarios/verify.sh + +# Build scenarios for a single language (typescript, python, go, csharp) +scenario-build-lang LANG: + #!/usr/bin/env bash + set -euo pipefail + echo "=== Building {{LANG}} scenarios ===" + PASS=0; FAIL=0 + + case "{{LANG}}" in + typescript) + (cd nodejs && npm ci --ignore-scripts --silent 2>/dev/null) || true + for target in $(find test/scenarios -path '*/typescript/package.json' | sort); do + dir=$(dirname "$target"); scenario="${dir#test/scenarios/}" + if (cd "$dir" && npm install --ignore-scripts >/dev/null 2>&1); then + printf " ✅ %s\n" "$scenario"; PASS=$((PASS + 1)) + else + printf " ❌ %s\n" "$scenario"; FAIL=$((FAIL + 1)) + fi + done + ;; + python) + for target in $(find test/scenarios -path '*/python/main.py' | sort); do + dir=$(dirname "$target"); scenario="${dir#test/scenarios/}" + if python3 -c "import ast; ast.parse(open('$target').read())" 2>/dev/null; then + printf " ✅ %s\n" "$scenario"; PASS=$((PASS + 1)) + else + printf " ❌ %s\n" "$scenario"; FAIL=$((FAIL + 1)) + fi + done + ;; + go) + for target in $(find test/scenarios -path '*/go/go.mod' | sort); do + dir=$(dirname "$target"); scenario="${dir#test/scenarios/}" + if (cd "$dir" && go build ./... >/dev/null 2>&1); then + printf " ✅ %s\n" "$scenario"; PASS=$((PASS + 1)) + else + printf " ❌ %s\n" "$scenario"; FAIL=$((FAIL + 1)) + fi + done + ;; + csharp) + for target in $(find test/scenarios -name '*.csproj' -path '*/csharp/*' | sort); do + dir=$(dirname "$target"); scenario="${dir#test/scenarios/}" + if (cd "$dir" && dotnet build --nologo -v quiet >/dev/null 2>&1); then + printf " ✅ %s\n" "$scenario"; PASS=$((PASS + 1)) + else + printf " ❌ %s\n" "$scenario"; FAIL=$((FAIL + 1)) + fi + done + ;; + *) + echo "Unknown language: {{LANG}}. Use: typescript, python, go, csharp" + exit 1 + ;; + esac + + echo "" + echo "{{LANG}} scenarios: $PASS passed, $FAIL failed" + [ "$FAIL" -eq 0 ] diff --git a/nodejs/README.md b/nodejs/README.md index 3a78f4199..31558b8ab 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -10,6 +10,19 @@ TypeScript SDK for programmatic control of GitHub Copilot CLI via JSON-RPC. npm install @github/copilot-sdk ``` +## Run the Sample + +Try the interactive chat sample (from the repo root): + +```bash +cd nodejs +npm ci +npm run build +cd samples +npm install +npm start +``` + ## Quick Start ```typescript @@ -108,9 +121,25 @@ Ping the server to check connectivity. Get current connection state. -##### `listSessions(): Promise` +##### `listSessions(filter?: SessionListFilter): Promise` + +List all available sessions. Optionally filter by working directory context. + +**SessionMetadata:** + +- `sessionId: string` - Unique session identifier +- `startTime: Date` - When the session was created +- `modifiedTime: Date` - When the session was last modified +- `summary?: string` - Optional session summary +- `isRemote: boolean` - Whether the session is remote +- `context?: SessionContext` - Working directory context from session creation + +**SessionContext:** -List all available sessions. +- `cwd: string` - Working directory where the session was created +- `gitRoot?: string` - Git repository root (if in a git repo) +- `repository?: string` - GitHub repository in "owner/repo" format +- `branch?: string` - Current git branch ##### `deleteSession(sessionId: string): Promise` diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 266d994e3..3cba7c816 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.405", + "@github/copilot": "^0.0.411", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -31,7 +31,7 @@ "vitest": "^4.0.18" }, "engines": { - "node": ">=24.0.0" + "node": ">=20.0.0" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -662,26 +662,26 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.405", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.405.tgz", - "integrity": "sha512-zp0kGSkoKrO4MTWefAxU5w2VEc02QnhPY3FmVxOeduh6ayDIz2V368mXxs46ThremdMnMyZPL1k989BW4NpOVw==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.411.tgz", + "integrity": "sha512-I3/7gw40Iu1O+kTyNPKJHNqDRyOebjsUW6wJsvSVrOpT0TNa3/lfm8xdS2XUuJWkp+PgEG/PRwF7u3DVNdP7bQ==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.405", - "@github/copilot-darwin-x64": "0.0.405", - "@github/copilot-linux-arm64": "0.0.405", - "@github/copilot-linux-x64": "0.0.405", - "@github/copilot-win32-arm64": "0.0.405", - "@github/copilot-win32-x64": "0.0.405" + "@github/copilot-darwin-arm64": "0.0.411", + "@github/copilot-darwin-x64": "0.0.411", + "@github/copilot-linux-arm64": "0.0.411", + "@github/copilot-linux-x64": "0.0.411", + "@github/copilot-win32-arm64": "0.0.411", + "@github/copilot-win32-x64": "0.0.411" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.405", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.405.tgz", - "integrity": "sha512-RVFpU1cEMqjR0rLpwLwbIfT7XzqqVoQX99G6nsj+WrHu3TIeCgfffyd2YShd4QwZYsMRoTfKB+rirQ+0G5Uiig==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.411.tgz", + "integrity": "sha512-dtr+iHxTS4f8HlV2JT9Fp0FFoxuiPWCnU3XGmrHK+rY6bX5okPC2daU5idvs77WKUGcH8yHTZtfbKYUiMxKosw==", "cpu": [ "arm64" ], @@ -695,9 +695,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.405", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.405.tgz", - "integrity": "sha512-Xj2FyPzpZlfqPTuMrXtPNEijSmm2ivHvyMWgy5Ijv7Slabxe+2s3WXDaokE3SQHodK6M0Yle2yrx9kxiwWA+qw==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.411.tgz", + "integrity": "sha512-zhdbQCbPi1L4iHClackSLx8POfklA+NX9RQLuS48HlKi/0KI/JlaDA/bdbIeMR79wjif5t9gnc/m+RTVmHlRtA==", "cpu": [ "x64" ], @@ -711,9 +711,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.405", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.405.tgz", - "integrity": "sha512-16Wiq8EYB6ghwqZdYytnNkcCN4sT3jyt9XkjfMxI5DDdjLuPc8wbj5VV5pw8S6lZvBL4eAwXGE3+fPqXKxH6GQ==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.411.tgz", + "integrity": "sha512-oZYZ7oX/7O+jzdTUcHkfD1A8YnNRW6mlUgdPjUg+5rXC43bwIdyatAnc0ObY21m9h8ghxGqholoLhm5WnGv1LQ==", "cpu": [ "arm64" ], @@ -727,9 +727,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.405", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.405.tgz", - "integrity": "sha512-HXpg7p235//pAuCvcL9m2EeIrL/K6OUEkFeHF3BFHzqUJR4a69gKLsxtUg0ZctypHqo2SehGCRAyVippTVlTyg==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.411.tgz", + "integrity": "sha512-nnXrKANmmGnkwa3ROlKdAhVNOx8daeMSE8Xh0o3ybKckFv4s38blhKdcxs0RJQRxgAk4p7XXGlDDKNRhurqF1g==", "cpu": [ "x64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.405", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.405.tgz", - "integrity": "sha512-4JCUMiRjP7zB3j1XpEtJq7b7cxTzuwDJ9o76jayAL8HL9NhqKZ6Ys6uxhDA6f/l0N2GVD1TEICxsnPgadz6srg==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.411.tgz", + "integrity": "sha512-h+Bovb2YVCQSeELZOO7zxv8uht45XHcvAkFbRsc1gf9dl109sSUJIcB4KAhs8Aznk28qksxz7kvdSgUWyQBlIA==", "cpu": [ "arm64" ], @@ -759,9 +759,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.405", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.405.tgz", - "integrity": "sha512-uHoJ9N8kZbTLbzgqBE1szHwLElv2f+P2OWlqmRSawQhwPl0s7u55dka7mZYvj2ZoNvIyb0OyShCO56OpmCcy/w==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.411.tgz", + "integrity": "sha512-xmOgi1lGvUBHQJWmq5AK1EP95+Y8xR4TFoK9OCSOaGbQ+LFcX2jF7iavnMolfWwddabew/AMQjsEHlXvbgMG8Q==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index b6e23f401..a0c85478b 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -25,7 +25,7 @@ "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", "lint:fix": "eslint --fix \"src/**/*.ts\" \"test/**/*.ts\"", "typecheck": "tsc --noEmit", - "generate:session-types": "tsx scripts/generate-session-types.ts", + "generate": "cd ../scripts/codegen && npm run generate", "update:protocol-version": "tsx scripts/update-protocol-version.ts", "prepublishOnly": "npm run build", "package": "npm run clean && npm run build && node scripts/set-version.js && npm pack && npm version 0.1.0 --no-git-tag-version --allow-same-version" @@ -40,7 +40,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.405", + "@github/copilot": "^0.0.411", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -62,7 +62,7 @@ "vitest": "^4.0.18" }, "engines": { - "node": ">=24.0.0" + "node": ">=20.0.0" }, "files": [ "dist/**/*", diff --git a/nodejs/samples/chat.ts b/nodejs/samples/chat.ts new file mode 100644 index 000000000..e2e05fdc3 --- /dev/null +++ b/nodejs/samples/chat.ts @@ -0,0 +1,35 @@ +import * as readline from "node:readline"; +import { CopilotClient, approveAll, type SessionEvent } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient(); + const session = await client.createSession({ + onPermissionRequest: approveAll, + }); + + session.on((event: SessionEvent) => { + let output: string | null = null; + if (event.type === "assistant.reasoning") { + output = `[reasoning: ${event.data.content}]`; + } else if (event.type === "tool.execution_start") { + output = `[tool: ${event.data.toolName}]`; + } + if (output) console.log(`\x1b[34m${output}\x1b[0m`); + }); + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const prompt = (q: string) => new Promise((r) => rl.question(q, r)); + + console.log("Chat with Copilot (Ctrl+C to exit)\n"); + + while (true) { + const input = await prompt("You: "); + if (!input.trim()) continue; + console.log(); + + const reply = await session.sendAndWait({ prompt: input }); + console.log(`\nAssistant: ${reply?.data.content}\n`); + } +} + +main().catch(console.error); diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json new file mode 100644 index 000000000..3272df55b --- /dev/null +++ b/nodejs/samples/package-lock.json @@ -0,0 +1,610 @@ +{ + "name": "copilot-sdk-sample", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "copilot-sdk-sample", + "dependencies": { + "@github/copilot-sdk": "file:.." + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.20.6" + } + }, + "..": { + "name": "@github/copilot-sdk", + "version": "0.1.8", + "license": "MIT", + "dependencies": { + "@github/copilot": "^0.0.411-0", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^25.2.0", + "@typescript-eslint/eslint-plugin": "^8.54.0", + "@typescript-eslint/parser": "^8.54.0", + "esbuild": "^0.27.2", + "eslint": "^9.0.0", + "glob": "^13.0.1", + "json-schema": "^0.4.0", + "json-schema-to-typescript": "^15.0.4", + "prettier": "^3.8.1", + "quicktype-core": "^23.2.6", + "rimraf": "^6.1.2", + "semver": "^7.7.3", + "tsx": "^4.20.6", + "typescript": "^5.0.0", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@github/copilot-sdk": { + "resolved": "..", + "link": true + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/nodejs/samples/package.json b/nodejs/samples/package.json new file mode 100644 index 000000000..7ff4cd9f5 --- /dev/null +++ b/nodejs/samples/package.json @@ -0,0 +1,14 @@ +{ + "name": "copilot-sdk-sample", + "type": "module", + "scripts": { + "start": "npx tsx chat.ts" + }, + "dependencies": { + "@github/copilot-sdk": "file:.." + }, + "devDependencies": { + "tsx": "^4.20.6", + "@types/node": "^22.0.0" + } +} diff --git a/nodejs/scripts/generate-csharp-session-types.ts b/nodejs/scripts/generate-csharp-session-types.ts deleted file mode 100644 index cf2951173..000000000 --- a/nodejs/scripts/generate-csharp-session-types.ts +++ /dev/null @@ -1,795 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -/** - * Custom C# code generator for session event types with proper polymorphic serialization. - * - * This generator produces: - * - A base SessionEvent class with [JsonPolymorphic] and [JsonDerivedType] attributes - * - Separate event classes (SessionStartEvent, AssistantMessageEvent, etc.) with strongly-typed Data - * - Separate Data classes for each event type with only the relevant properties - * - * This approach provides type-safe access to event data instead of a single Data class with 60+ nullable properties. - */ - -import type { JSONSchema7 } from "json-schema"; - -interface EventVariant { - typeName: string; // e.g., "session.start" - className: string; // e.g., "SessionStartEvent" - dataClassName: string; // e.g., "SessionStartData" - dataSchema: JSONSchema7; - ephemeralConst?: boolean; // if ephemeral has a const value -} - -/** - * Convert a type string like "session.start" to PascalCase class name like "SessionStart" - */ -function typeToClassName(typeName: string): string { - return typeName - .split(/[._]/) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(""); -} - -/** - * Convert a property name to PascalCase for C# - */ -function toPascalCase(name: string): string { - // Handle snake_case - if (name.includes("_")) { - return name - .split("_") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(""); - } - // Handle camelCase - return name.charAt(0).toUpperCase() + name.slice(1); -} - -/** - * Map JSON Schema type to C# type - */ -function schemaTypeToCSharp( - schema: JSONSchema7, - required: boolean, - knownTypes: Map, - parentClassName?: string, - propName?: string, - enumOutput?: string[] -): string { - if (schema.anyOf) { - // Handle nullable types (anyOf with null) - const nonNull = schema.anyOf.filter((s) => typeof s === "object" && s.type !== "null"); - if (nonNull.length === 1 && typeof nonNull[0] === "object") { - return ( - schemaTypeToCSharp( - nonNull[0] as JSONSchema7, - false, - knownTypes, - parentClassName, - propName, - enumOutput - ) + "?" - ); - } - } - - if (schema.enum && parentClassName && propName && enumOutput) { - // Generate C# enum - const enumName = getOrCreateEnum( - parentClassName, - propName, - schema.enum as string[], - enumOutput - ); - return required ? enumName : `${enumName}?`; - } - - if (schema.$ref) { - const refName = schema.$ref.split("/").pop()!; - return knownTypes.get(refName) || refName; - } - - const type = schema.type; - const format = schema.format; - - if (type === "string") { - if (format === "uuid") return required ? "Guid" : "Guid?"; - if (format === "date-time") return required ? "DateTimeOffset" : "DateTimeOffset?"; - return required ? "string" : "string?"; - } - if (type === "number" || type === "integer") { - return required ? "double" : "double?"; - } - if (type === "boolean") { - return required ? "bool" : "bool?"; - } - if (type === "array") { - const items = schema.items as JSONSchema7 | undefined; - const itemType = items ? schemaTypeToCSharp(items, true, knownTypes) : "object"; - return required ? `${itemType}[]` : `${itemType}[]?`; - } - if (type === "object") { - if (schema.additionalProperties) { - const valueSchema = schema.additionalProperties; - if (typeof valueSchema === "object") { - const valueType = schemaTypeToCSharp(valueSchema as JSONSchema7, true, knownTypes); - return required ? `Dictionary` : `Dictionary?`; - } - return required ? "Dictionary" : "Dictionary?"; - } - return required ? "object" : "object?"; - } - - return required ? "object" : "object?"; -} - -/** - * Event types to exclude from generation (internal/legacy types) - */ -const EXCLUDED_EVENT_TYPES = new Set(["session.import_legacy"]); - -/** - * Track enums that have been generated to avoid duplicates - */ -const generatedEnums = new Map(); - -/** - * Generate a C# enum name from the context - */ -function generateEnumName(parentClassName: string, propName: string): string { - return `${parentClassName}${propName}`; -} - -/** - * Get or create an enum for a given set of values. - * Returns the enum name and whether it's newly generated. - */ -function getOrCreateEnum( - parentClassName: string, - propName: string, - values: string[], - enumOutput: string[] -): string { - // Create a key based on the sorted values to detect duplicates - const valuesKey = [...values].sort().join("|"); - - // Check if we already have an enum with these exact values - for (const [, existing] of generatedEnums) { - const existingKey = [...existing.values].sort().join("|"); - if (existingKey === valuesKey) { - return existing.enumName; - } - } - - const enumName = generateEnumName(parentClassName, propName); - generatedEnums.set(enumName, { enumName, values }); - - // Generate the enum code with JsonConverter and JsonStringEnumMemberName attributes - const lines: string[] = []; - lines.push(`[JsonConverter(typeof(JsonStringEnumConverter<${enumName}>))]`); - lines.push(`public enum ${enumName}`); - lines.push(`{`); - for (const value of values) { - const memberName = toPascalCaseEnumMember(value); - lines.push(` [JsonStringEnumMemberName("${value}")]`); - lines.push(` ${memberName},`); - } - lines.push(`}`); - lines.push(""); - - enumOutput.push(lines.join("\n")); - return enumName; -} - -/** - * Convert a string value to a valid C# enum member name - */ -function toPascalCaseEnumMember(value: string): string { - // Handle special characters and convert to PascalCase - return value - .split(/[-_.]/) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(""); -} - -/** - * Extract event variants from the schema's anyOf - */ -function extractEventVariants(schema: JSONSchema7): EventVariant[] { - const sessionEvent = schema.definitions?.SessionEvent as JSONSchema7; - if (!sessionEvent?.anyOf) { - throw new Error("Schema must have SessionEvent definition with anyOf"); - } - - return sessionEvent.anyOf - .map((variant) => { - if (typeof variant !== "object" || !variant.properties) { - throw new Error("Invalid variant in anyOf"); - } - - const typeSchema = variant.properties.type as JSONSchema7; - const typeName = typeSchema?.const as string; - if (!typeName) { - throw new Error("Variant must have type.const"); - } - - const baseName = typeToClassName(typeName); - const ephemeralSchema = variant.properties.ephemeral as JSONSchema7 | undefined; - - return { - typeName, - className: `${baseName}Event`, - dataClassName: `${baseName}Data`, - dataSchema: variant.properties.data as JSONSchema7, - ephemeralConst: ephemeralSchema?.const as boolean | undefined, - }; - }) - .filter((variant) => !EXCLUDED_EVENT_TYPES.has(variant.typeName)); -} - -/** - * Generate C# code for a Data class - */ -function generateDataClass( - variant: EventVariant, - knownTypes: Map, - nestedClasses: Map, - enumOutput: string[] -): string { - const lines: string[] = []; - const dataSchema = variant.dataSchema; - - if (!dataSchema?.properties) { - lines.push(`public partial class ${variant.dataClassName} { }`); - return lines.join("\n"); - } - - const required = new Set(dataSchema.required || []); - - lines.push(`public partial class ${variant.dataClassName}`); - lines.push(`{`); - - for (const [propName, propSchema] of Object.entries(dataSchema.properties)) { - if (typeof propSchema !== "object") continue; - - const isRequired = required.has(propName); - const csharpName = toPascalCase(propName); - const csharpType = resolvePropertyType( - propSchema as JSONSchema7, - variant.dataClassName, - csharpName, - isRequired, - knownTypes, - nestedClasses, - enumOutput - ); - - const isNullableType = csharpType.endsWith("?"); - if (!isRequired) { - lines.push( - ` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` - ); - } - lines.push(` [JsonPropertyName("${propName}")]`); - - const requiredModifier = isRequired && !isNullableType ? "required " : ""; - lines.push(` public ${requiredModifier}${csharpType} ${csharpName} { get; set; }`); - lines.push(""); - } - - // Remove trailing empty line - if (lines[lines.length - 1] === "") { - lines.pop(); - } - - lines.push(`}`); - return lines.join("\n"); -} - -/** - * Generate a nested class for complex object properties. - * This function recursively handles nested objects, arrays of objects, and anyOf unions. - */ -function generateNestedClass( - className: string, - schema: JSONSchema7, - knownTypes: Map, - nestedClasses: Map, - enumOutput: string[] -): string { - const lines: string[] = []; - const required = new Set(schema.required || []); - - lines.push(`public partial class ${className}`); - lines.push(`{`); - - if (schema.properties) { - for (const [propName, propSchema] of Object.entries(schema.properties)) { - if (typeof propSchema !== "object") continue; - - const isRequired = required.has(propName); - const csharpName = toPascalCase(propName); - let csharpType = resolvePropertyType( - propSchema as JSONSchema7, - className, - csharpName, - isRequired, - knownTypes, - nestedClasses, - enumOutput - ); - - if (!isRequired) { - lines.push( - ` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]` - ); - } - lines.push(` [JsonPropertyName("${propName}")]`); - - const isNullableType = csharpType.endsWith("?"); - const requiredModifier = isRequired && !isNullableType ? "required " : ""; - lines.push(` public ${requiredModifier}${csharpType} ${csharpName} { get; set; }`); - lines.push(""); - } - } - - // Remove trailing empty line - if (lines[lines.length - 1] === "") { - lines.pop(); - } - - lines.push(`}`); - return lines.join("\n"); -} - -/** - * Find a discriminator property shared by all variants in an anyOf. - * Returns the property name and the mapping of const values to variant schemas. - */ -function findDiscriminator(variants: JSONSchema7[]): { property: string; mapping: Map } | null { - if (variants.length === 0) return null; - - // Look for a property with a const value in all variants - const firstVariant = variants[0]; - if (!firstVariant.properties) return null; - - for (const [propName, propSchema] of Object.entries(firstVariant.properties)) { - if (typeof propSchema !== "object") continue; - const schema = propSchema as JSONSchema7; - if (schema.const === undefined) continue; - - // Check if all variants have this property with a const value - const mapping = new Map(); - let isValidDiscriminator = true; - - for (const variant of variants) { - if (!variant.properties) { - isValidDiscriminator = false; - break; - } - const variantProp = variant.properties[propName]; - if (typeof variantProp !== "object") { - isValidDiscriminator = false; - break; - } - const variantSchema = variantProp as JSONSchema7; - if (variantSchema.const === undefined) { - isValidDiscriminator = false; - break; - } - mapping.set(String(variantSchema.const), variant); - } - - if (isValidDiscriminator && mapping.size === variants.length) { - return { property: propName, mapping }; - } - } - - return null; -} - -/** - * Generate a polymorphic base class and derived classes for a discriminated union. - */ -function generatePolymorphicClasses( - baseClassName: string, - discriminatorProperty: string, - variants: JSONSchema7[], - knownTypes: Map, - nestedClasses: Map, - enumOutput: string[] -): string { - const lines: string[] = []; - const discriminatorInfo = findDiscriminator(variants)!; - - // Generate base class with JsonPolymorphic attribute - lines.push(`[JsonPolymorphic(`); - lines.push(` TypeDiscriminatorPropertyName = "${discriminatorProperty}",`); - lines.push(` UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)]`); - - // Add JsonDerivedType attributes for each variant - for (const [constValue] of discriminatorInfo.mapping) { - const derivedClassName = `${baseClassName}${toPascalCase(constValue)}`; - lines.push(`[JsonDerivedType(typeof(${derivedClassName}), "${constValue}")]`); - } - - lines.push(`public partial class ${baseClassName}`); - lines.push(`{`); - lines.push(` [JsonPropertyName("${discriminatorProperty}")]`); - lines.push(` public virtual string ${toPascalCase(discriminatorProperty)} { get; set; } = string.Empty;`); - lines.push(`}`); - lines.push(""); - - // Generate derived classes - for (const [constValue, variant] of discriminatorInfo.mapping) { - const derivedClassName = `${baseClassName}${toPascalCase(constValue)}`; - const derivedCode = generateDerivedClass( - derivedClassName, - baseClassName, - discriminatorProperty, - constValue, - variant, - knownTypes, - nestedClasses, - enumOutput - ); - nestedClasses.set(derivedClassName, derivedCode); - } - - return lines.join("\n"); -} - -/** - * Generate a derived class for a discriminated union variant. - */ -function generateDerivedClass( - className: string, - baseClassName: string, - discriminatorProperty: string, - discriminatorValue: string, - schema: JSONSchema7, - knownTypes: Map, - nestedClasses: Map, - enumOutput: string[] -): string { - const lines: string[] = []; - const required = new Set(schema.required || []); - - lines.push(`public partial class ${className} : ${baseClassName}`); - lines.push(`{`); - - // Override the discriminator property - lines.push(` [JsonIgnore]`); - lines.push(` public override string ${toPascalCase(discriminatorProperty)} => "${discriminatorValue}";`); - lines.push(""); - - if (schema.properties) { - for (const [propName, propSchema] of Object.entries(schema.properties)) { - if (typeof propSchema !== "object") continue; - // Skip the discriminator property (already in base class) - if (propName === discriminatorProperty) continue; - - const isRequired = required.has(propName); - const csharpName = toPascalCase(propName); - const csharpType = resolvePropertyType( - propSchema as JSONSchema7, - className, - csharpName, - isRequired, - knownTypes, - nestedClasses, - enumOutput - ); - - if (!isRequired) { - lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); - } - lines.push(` [JsonPropertyName("${propName}")]`); - - const isNullableType = csharpType.endsWith("?"); - const requiredModifier = isRequired && !isNullableType ? "required " : ""; - lines.push(` public ${requiredModifier}${csharpType} ${csharpName} { get; set; }`); - lines.push(""); - } - } - - // Remove trailing empty line - if (lines[lines.length - 1] === "") { - lines.pop(); - } - - lines.push(`}`); - return lines.join("\n"); -} - -/** - * Resolve the C# type for a property, generating nested classes as needed. - * Handles objects and arrays of objects. - */ -function resolvePropertyType( - propSchema: JSONSchema7, - parentClassName: string, - propName: string, - isRequired: boolean, - knownTypes: Map, - nestedClasses: Map, - enumOutput: string[] -): string { - // Handle anyOf - simplify to nullable of the non-null type or object - if (propSchema.anyOf) { - const hasNull = propSchema.anyOf.some( - (s) => typeof s === "object" && (s as JSONSchema7).type === "null" - ); - const nonNullTypes = propSchema.anyOf.filter( - (s) => typeof s === "object" && (s as JSONSchema7).type !== "null" - ); - if (nonNullTypes.length === 1) { - // Simple nullable - recurse with the inner type, marking as not required if null is an option - return resolvePropertyType( - nonNullTypes[0] as JSONSchema7, - parentClassName, - propName, - isRequired && !hasNull, - knownTypes, - nestedClasses, - enumOutput - ); - } - // Complex union - use object, nullable if null is in the union or property is not required - return (hasNull || !isRequired) ? "object?" : "object"; - } - - // Handle enum types - if (propSchema.enum && Array.isArray(propSchema.enum)) { - const enumName = getOrCreateEnum( - parentClassName, - propName, - propSchema.enum as string[], - enumOutput - ); - return isRequired ? enumName : `${enumName}?`; - } - - // Handle nested object types - if (propSchema.type === "object" && propSchema.properties) { - const nestedClassName = `${parentClassName}${propName}`; - const nestedCode = generateNestedClass( - nestedClassName, - propSchema, - knownTypes, - nestedClasses, - enumOutput - ); - nestedClasses.set(nestedClassName, nestedCode); - return isRequired ? nestedClassName : `${nestedClassName}?`; - } - - // Handle array of objects - if (propSchema.type === "array" && propSchema.items) { - const items = propSchema.items as JSONSchema7; - - // Array of discriminated union (anyOf with shared discriminator) - if (items.anyOf && Array.isArray(items.anyOf)) { - const variants = items.anyOf.filter((v): v is JSONSchema7 => typeof v === "object"); - const discriminatorInfo = findDiscriminator(variants); - - if (discriminatorInfo) { - const baseClassName = `${parentClassName}${propName}Item`; - const polymorphicCode = generatePolymorphicClasses( - baseClassName, - discriminatorInfo.property, - variants, - knownTypes, - nestedClasses, - enumOutput - ); - nestedClasses.set(baseClassName, polymorphicCode); - return isRequired ? `${baseClassName}[]` : `${baseClassName}[]?`; - } - } - - // Array of objects with properties - if (items.type === "object" && items.properties) { - const itemClassName = `${parentClassName}${propName}Item`; - const nestedCode = generateNestedClass( - itemClassName, - items, - knownTypes, - nestedClasses, - enumOutput - ); - nestedClasses.set(itemClassName, nestedCode); - return isRequired ? `${itemClassName}[]` : `${itemClassName}[]?`; - } - - // Array of enums - if (items.enum && Array.isArray(items.enum)) { - const enumName = getOrCreateEnum( - parentClassName, - `${propName}Item`, - items.enum as string[], - enumOutput - ); - return isRequired ? `${enumName}[]` : `${enumName}[]?`; - } - - // Simple array type - const itemType = schemaTypeToCSharp( - items, - true, - knownTypes, - parentClassName, - propName, - enumOutput - ); - return isRequired ? `${itemType}[]` : `${itemType}[]?`; - } - - // Default: use basic type mapping - return schemaTypeToCSharp( - propSchema, - isRequired, - knownTypes, - parentClassName, - propName, - enumOutput - ); -} - -/** - * Generate the complete C# file - */ -export function generateCSharpSessionTypes(schema: JSONSchema7, generatedAt: string): string { - // Clear the generated enums map from any previous run - generatedEnums.clear(); - - const variants = extractEventVariants(schema); - const knownTypes = new Map(); - const nestedClasses = new Map(); - const enumOutput: string[] = []; - - const lines: string[] = []; - - // File header - lines.push(`/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -// AUTO-GENERATED FILE - DO NOT EDIT -// -// Generated from: @github/copilot/session-events.schema.json -// Generated by: scripts/generate-session-types.ts -// Generated at: ${generatedAt} -// -// To update these types: -// 1. Update the schema in copilot-agent-runtime -// 2. Run: npm run generate:session-types - -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace GitHub.Copilot.SDK; -`); - - // Generate base class with JsonPolymorphic attributes - lines.push(`/// `); - lines.push( - `/// Base class for all session events with polymorphic JSON serialization.` - ); - lines.push(`/// `); - lines.push(`[JsonPolymorphic(`); - lines.push(` TypeDiscriminatorPropertyName = "type",`); - lines.push( - ` UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)]` - ); - - // Generate JsonDerivedType attributes for each variant (alphabetized) - for (const variant of [...variants].sort((a, b) => a.typeName.localeCompare(b.typeName))) { - lines.push( - `[JsonDerivedType(typeof(${variant.className}), "${variant.typeName}")]` - ); - } - - lines.push(`public abstract partial class SessionEvent`); - lines.push(`{`); - lines.push(` [JsonPropertyName("id")]`); - lines.push(` public Guid Id { get; set; }`); - lines.push(""); - lines.push(` [JsonPropertyName("timestamp")]`); - lines.push(` public DateTimeOffset Timestamp { get; set; }`); - lines.push(""); - lines.push(` [JsonPropertyName("parentId")]`); - lines.push(` public Guid? ParentId { get; set; }`); - lines.push(""); - lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); - lines.push(` [JsonPropertyName("ephemeral")]`); - lines.push(` public bool? Ephemeral { get; set; }`); - lines.push(""); - lines.push(` /// `); - lines.push(` /// The event type discriminator.`); - lines.push(` /// `); - lines.push(` [JsonIgnore]`); - lines.push(` public abstract string Type { get; }`); - lines.push(""); - lines.push(` public static SessionEvent FromJson(string json) =>`); - lines.push( - ` JsonSerializer.Deserialize(json, SessionEventsJsonContext.Default.SessionEvent)!;` - ); - lines.push(""); - lines.push(` public string ToJson() =>`); - lines.push( - ` JsonSerializer.Serialize(this, SessionEventsJsonContext.Default.SessionEvent);` - ); - lines.push(`}`); - lines.push(""); - - // Generate each event class - for (const variant of variants) { - lines.push(`/// `); - lines.push(`/// Event: ${variant.typeName}`); - lines.push(`/// `); - lines.push(`public partial class ${variant.className} : SessionEvent`); - lines.push(`{`); - lines.push(` [JsonIgnore]`); - lines.push(` public override string Type => "${variant.typeName}";`); - lines.push(""); - lines.push(` [JsonPropertyName("data")]`); - lines.push(` public required ${variant.dataClassName} Data { get; set; }`); - lines.push(`}`); - lines.push(""); - } - - // Generate data classes - for (const variant of variants) { - const dataClass = generateDataClass(variant, knownTypes, nestedClasses, enumOutput); - lines.push(dataClass); - lines.push(""); - } - - // Generate nested classes - for (const [, nestedCode] of nestedClasses) { - lines.push(nestedCode); - lines.push(""); - } - - // Generate enums - for (const enumCode of enumOutput) { - lines.push(enumCode); - } - - // Collect all serializable types (sorted alphabetically) - const serializableTypes: string[] = []; - - // Add SessionEvent base class - serializableTypes.push("SessionEvent"); - - // Add all event classes and their data classes - for (const variant of variants) { - serializableTypes.push(variant.className); - serializableTypes.push(variant.dataClassName); - } - - // Add all nested classes - for (const [className] of nestedClasses) { - serializableTypes.push(className); - } - - // Sort alphabetically - serializableTypes.sort((a, b) => a.localeCompare(b)); - - // Generate JsonSerializerContext with JsonSerializable attributes - lines.push(`[JsonSourceGenerationOptions(`); - lines.push(` JsonSerializerDefaults.Web,`); - lines.push(` AllowOutOfOrderMetadataProperties = true,`); - lines.push(` NumberHandling = JsonNumberHandling.AllowReadingFromString,`); - lines.push(` DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]`); - for (const typeName of serializableTypes) { - lines.push(`[JsonSerializable(typeof(${typeName}))]`); - } - lines.push(`internal partial class SessionEventsJsonContext : JsonSerializerContext;`); - - return lines.join("\n"); -} diff --git a/nodejs/scripts/generate-session-types.ts b/nodejs/scripts/generate-session-types.ts deleted file mode 100644 index 8a0063a3e..000000000 --- a/nodejs/scripts/generate-session-types.ts +++ /dev/null @@ -1,373 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -/** - * Generate session event types for all SDKs from the JSON schema - * - * This script reads the session-events.schema.json from the @github/copilot package - * (which should be npm linked from copilot-agent-runtime/dist-cli) and generates - * TypeScript, Python, Go, and C# type definitions for all SDKs. - * - * Workflow: - * 1. The schema is defined in copilot-agent-runtime using Zod schemas - * 2. copilot-agent-runtime/script/generate-session-types.ts generates the JSON schema - * 3. copilot-agent-runtime/esbuild.ts copies the schema to dist-cli/ - * 4. This script reads the schema from the linked @github/copilot package - * 5. Generates types for nodejs/src/generated/, python/copilot/generated/, go/generated/, and dotnet/src/Generated/ - * - * Usage: - * npm run generate:session-types - */ - -import { execFile } from "child_process"; -import fs from "fs/promises"; -import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; -import { compile } from "json-schema-to-typescript"; -import path from "path"; -import { FetchingJSONSchemaStore, InputData, JSONSchemaInput, quicktype } from "quicktype-core"; -import { fileURLToPath } from "url"; -import { promisify } from "util"; -import { generateCSharpSessionTypes } from "./generate-csharp-session-types.js"; - -const execFileAsync = promisify(execFile); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -async function getSchemaPath(): Promise { - // Read from the @github/copilot package - const schemaPath = path.join( - __dirname, - "../node_modules/@github/copilot/schemas/session-events.schema.json" - ); - - try { - await fs.access(schemaPath); - console.log(`✅ Found schema at: ${schemaPath}`); - return schemaPath; - } catch (_error) { - throw new Error( - `Schema file not found at ${schemaPath}. ` + - `Make sure @github/copilot package is installed or linked.` - ); - } -} - -async function generateTypeScriptTypes(schemaPath: string) { - console.log("🔄 Generating TypeScript types from JSON Schema..."); - - const schema = JSON.parse(await fs.readFile(schemaPath, "utf-8")) as JSONSchema7; - const processedSchema = postProcessSchema(schema); - - const ts = await compile(processedSchema, "SessionEvent", { - bannerComment: `/** - * AUTO-GENERATED FILE - DO NOT EDIT - * - * Generated from: @github/copilot/session-events.schema.json - * Generated by: scripts/generate-session-types.ts - * Generated at: ${new Date().toISOString()} - * - * To update these types: - * 1. Update the schema in copilot-agent-runtime - * 2. Run: npm run generate:session-types - */`, - style: { - semi: true, - singleQuote: false, - trailingComma: "all", - }, - additionalProperties: false, // Stricter types - }); - - const outputPath = path.join(__dirname, "../src/generated/session-events.ts"); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, ts, "utf-8"); - - console.log(`✅ Generated TypeScript types: ${outputPath}`); -} - -/** - * Event types to exclude from generation (internal/legacy types) - */ -const EXCLUDED_EVENT_TYPES = new Set(["session.import_legacy"]); - -/** - * Post-process JSON Schema to make it compatible with quicktype - * Converts boolean const values to enum with single value - * Filters out excluded event types - */ -function postProcessSchema(schema: JSONSchema7): JSONSchema7 { - if (typeof schema !== "object" || schema === null) { - return schema; - } - - const processed: JSONSchema7 = { ...schema }; - - // Handle const with boolean values - convert to enum with single value - if ("const" in processed && typeof processed.const === "boolean") { - const constValue = processed.const; - delete processed.const; - processed.enum = [constValue]; - } - - // Recursively process all properties - if (processed.properties) { - const newProperties: Record = {}; - for (const [key, value] of Object.entries(processed.properties)) { - if (typeof value === "object" && value !== null) { - newProperties[key] = postProcessSchema(value as JSONSchema7); - } else { - newProperties[key] = value; - } - } - processed.properties = newProperties; - } - - // Process items (for arrays) - if (processed.items) { - if (typeof processed.items === "object" && !Array.isArray(processed.items)) { - processed.items = postProcessSchema(processed.items as JSONSchema7); - } else if (Array.isArray(processed.items)) { - processed.items = processed.items.map((item) => - typeof item === "object" ? postProcessSchema(item as JSONSchema7) : item - ) as JSONSchema7Definition[]; - } - } - - // Process anyOf, allOf, oneOf - also filter out excluded event types - for (const combiner of ["anyOf", "allOf", "oneOf"] as const) { - if (processed[combiner]) { - processed[combiner] = processed[combiner]!.filter((item) => { - if (typeof item !== "object") return true; - const typeConst = (item as JSONSchema7).properties?.type; - if (typeof typeConst === "object" && "const" in typeConst) { - return !EXCLUDED_EVENT_TYPES.has(typeConst.const as string); - } - return true; - }).map((item) => - typeof item === "object" ? postProcessSchema(item as JSONSchema7) : item - ) as JSONSchema7Definition[]; - } - } - - // Process definitions - if (processed.definitions) { - const newDefinitions: Record = {}; - for (const [key, value] of Object.entries(processed.definitions)) { - if (typeof value === "object" && value !== null) { - newDefinitions[key] = postProcessSchema(value as JSONSchema7); - } else { - newDefinitions[key] = value; - } - } - processed.definitions = newDefinitions; - } - - // Process additionalProperties if it's a schema - if (typeof processed.additionalProperties === "object") { - processed.additionalProperties = postProcessSchema( - processed.additionalProperties as JSONSchema7 - ); - } - - return processed; -} - -async function generatePythonTypes(schemaPath: string) { - console.log("🔄 Generating Python types from JSON Schema..."); - - const schemaContent = await fs.readFile(schemaPath, "utf-8"); - const schema = JSON.parse(schemaContent) as JSONSchema7; - - // Resolve the $ref at the root level and get the actual schema - const resolvedSchema = (schema.definitions?.SessionEvent as JSONSchema7) || schema; - - // Post-process to fix boolean const values - const processedSchema = postProcessSchema(resolvedSchema); - - const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); - await schemaInput.addSource({ - name: "SessionEvent", - schema: JSON.stringify(processedSchema), - }); - - const inputData = new InputData(); - inputData.addInput(schemaInput); - - const result = await quicktype({ - inputData, - lang: "python", - rendererOptions: { - "python-version": "3.7", - }, - }); - - let generatedCode = result.lines.join("\n"); - - // Fix Python dataclass field ordering issue: - // Quicktype doesn't support default values in schemas, so it generates "arguments: Any" - // (without default) that comes after Optional fields (with defaults), violating Python's - // dataclass rules. We post-process to add "= None" to these unconstrained "Any" fields. - generatedCode = generatedCode.replace(/: Any$/gm, ": Any = None"); - - // Add UNKNOWN enum value and _missing_ handler for forward compatibility - // This ensures that new event types from the server don't cause errors - generatedCode = generatedCode.replace( - /^(class SessionEventType\(Enum\):.*?)(^\s*\n@dataclass)/ms, - `$1 # UNKNOWN is used for forward compatibility - new event types from the server - # will map to this value instead of raising an error - UNKNOWN = "unknown" - - @classmethod - def _missing_(cls, value: object) -> "SessionEventType": - """Handle unknown event types gracefully for forward compatibility.""" - return cls.UNKNOWN - -$2` - ); - - const banner = `""" -AUTO-GENERATED FILE - DO NOT EDIT - -Generated from: @github/copilot/session-events.schema.json -Generated by: scripts/generate-session-types.ts -Generated at: ${new Date().toISOString()} - -To update these types: -1. Update the schema in copilot-agent-runtime -2. Run: npm run generate:session-types -""" - -`; - - const outputPath = path.join(__dirname, "../../python/copilot/generated/session_events.py"); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, banner + generatedCode, "utf-8"); - - console.log(`✅ Generated Python types: ${outputPath}`); -} - -async function formatGoFile(filePath: string): Promise { - try { - await execFileAsync("go", ["fmt", filePath]); - console.log(`✅ Formatted Go file with go fmt: ${filePath}`); - } catch (error: unknown) { - if (error instanceof Error && "code" in error) { - if (error.code === "ENOENT") { - console.warn(`⚠️ go fmt not available - skipping formatting for ${filePath}`); - } else { - console.warn(`⚠️ go fmt failed for ${filePath}: ${error.message}`); - } - } - } -} - -async function generateGoTypes(schemaPath: string) { - console.log("🔄 Generating Go types from JSON Schema..."); - - const schemaContent = await fs.readFile(schemaPath, "utf-8"); - const schema = JSON.parse(schemaContent) as JSONSchema7; - - // Resolve the $ref at the root level and get the actual schema - const resolvedSchema = (schema.definitions?.SessionEvent as JSONSchema7) || schema; - - // Post-process to fix boolean const values - const processedSchema = postProcessSchema(resolvedSchema); - - const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); - await schemaInput.addSource({ - name: "SessionEvent", - schema: JSON.stringify(processedSchema), - }); - - const inputData = new InputData(); - inputData.addInput(schemaInput); - - const result = await quicktype({ - inputData, - lang: "go", - rendererOptions: { - package: "copilot", - }, - }); - - const generatedCode = result.lines.join("\n"); - const banner = `// AUTO-GENERATED FILE - DO NOT EDIT -// -// Generated from: @github/copilot/session-events.schema.json -// Generated by: scripts/generate-session-types.ts -// Generated at: ${new Date().toISOString()} -// -// To update these types: -// 1. Update the schema in copilot-agent-runtime -// 2. Run: npm run generate:session-types - -`; - - const outputPath = path.join(__dirname, "../../go/generated_session_events.go"); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, banner + generatedCode, "utf-8"); - - console.log(`✅ Generated Go types: ${outputPath}`); - - await formatGoFile(outputPath); -} - -async function formatCSharpFile(filePath: string): Promise { - try { - // Get the directory containing the .csproj file - const projectDir = path.join(__dirname, "../../dotnet/src"); - const projectFile = path.join(projectDir, "GitHub.Copilot.SDK.csproj"); - - // dotnet format needs to be run from the project directory or with --workspace - await execFileAsync("dotnet", ["format", projectFile, "--include", filePath]); - console.log(`✅ Formatted C# file with dotnet format: ${filePath}`); - } catch (error: unknown) { - if (error instanceof Error && "code" in error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - console.warn( - `⚠️ dotnet format not available - skipping formatting for ${filePath}` - ); - } else { - console.warn( - `⚠️ dotnet format failed for ${filePath}: ${(error as Error).message}` - ); - } - } - } -} - -async function generateCSharpTypes(schemaPath: string) { - console.log("🔄 Generating C# types from JSON Schema..."); - - const schemaContent = await fs.readFile(schemaPath, "utf-8"); - const schema = JSON.parse(schemaContent) as JSONSchema7; - - const generatedAt = new Date().toISOString(); - const generatedCode = generateCSharpSessionTypes(schema, generatedAt); - - const outputPath = path.join(__dirname, "../../dotnet/src/Generated/SessionEvents.cs"); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, generatedCode, "utf-8"); - - console.log(`✅ Generated C# types: ${outputPath}`); - - await formatCSharpFile(outputPath); -} - -async function main() { - try { - const schemaPath = await getSchemaPath(); - await generateTypeScriptTypes(schemaPath); - await generatePythonTypes(schemaPath); - await generateGoTypes(schemaPath); - await generateCSharpTypes(schemaPath); - console.log("✅ Type generation complete!"); - } catch (error) { - console.error("❌ Type generation failed:", error); - process.exit(1); - } -} - -main(); diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 7ffc57e5e..b32ff0a75 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -22,6 +22,7 @@ import { StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js"; +import { createServerRpc } from "./generated/rpc.js"; import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; import { CopilotSession } from "./session.js"; import type { ProtocolAdapter, ProtocolConnection } from "./protocols/protocol-adapter.js"; @@ -35,10 +36,12 @@ import type { ModelInfo, ResumeSessionConfig, SessionConfig, + SessionContext, SessionEvent, SessionLifecycleEvent, SessionLifecycleEventType, SessionLifecycleHandler, + SessionListFilter, SessionMetadata, Tool, ToolCallRequestPayload, @@ -106,6 +109,13 @@ function toJsonSchema(parameters: Tool["parameters"]): Record | * ``` */ +function getNodeExecPath(): string { + if (process.versions.bun) { + return "node"; + } + return process.execPath; +} + /** * Gets the path to the bundled CLI from the @github/copilot package. * Uses index.js directly rather than npm-loader.js (which spawns the native binary). @@ -127,6 +137,7 @@ export class CopilotClient { private actualHost: string = "localhost"; private state: ConnectionState = "disconnected"; private sessions: Map = new Map(); + private stderrBuffer: string = ""; // Captures CLI stderr for error messages private options: Required< Omit > & { @@ -145,6 +156,22 @@ export class CopilotClient { SessionLifecycleEventType, Set<(event: SessionLifecycleEvent) => void> > = new Map(); + private _rpc: ReturnType | null = null; + private processExitPromise: Promise | null = null; // Rejects when CLI process exits + + /** + * Typed server-scoped RPC methods. + * @throws Error if the client is not connected + */ + get rpc(): ReturnType { + if (!this.connection) { + throw new Error("Client is not connected. Call start() first."); + } + if (!this._rpc) { + this._rpc = createServerRpc(this.connection); + } + return this._rpc; + } /** * Creates a new CopilotClient instance. @@ -377,6 +404,7 @@ export class CopilotClient { ); } this.connection = null; + this._rpc = null; } // Clear models cache @@ -411,6 +439,8 @@ export class CopilotClient { this.state = "disconnected"; this.actualPort = null; + this.stderrBuffer = ""; + this.processExitPromise = null; return errors; } @@ -464,6 +494,7 @@ export class CopilotClient { // Ignore errors during force stop } this.connection = null; + this._rpc = null; } // Clear models cache @@ -490,6 +521,8 @@ export class CopilotClient { this.state = "disconnected"; this.actualPort = null; + this.stderrBuffer = ""; + this.processExitPromise = null; } /** @@ -532,6 +565,7 @@ export class CopilotClient { const response = await this.connection!.sendRequest("session.create", { model: config.model, sessionId: config.sessionId, + clientName: config.clientName, reasoningEffort: config.reasoningEffort, tools: config.tools?.map((tool) => ({ name: tool.name, @@ -542,12 +576,13 @@ export class CopilotClient { availableTools: config.availableTools, excludedTools: config.excludedTools, provider: config.provider, - requestPermission: !!config.onPermissionRequest, + requestPermission: true, requestUserInput: !!config.onUserInputRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), workingDirectory: config.workingDirectory, streaming: config.streaming, mcpServers: config.mcpServers, + envValueMode: "direct", customAgents: config.customAgents, configDir: config.configDir, skillDirectories: config.skillDirectories, @@ -612,6 +647,7 @@ export class CopilotClient { const response = await this.connection!.sendRequest("session.resume", { sessionId, + clientName: config.clientName, model: config.model, reasoningEffort: config.reasoningEffort, systemMessage: config.systemMessage, @@ -623,13 +659,14 @@ export class CopilotClient { parameters: toJsonSchema(tool.parameters), })), provider: config.provider, - requestPermission: !!config.onPermissionRequest, + requestPermission: true, requestUserInput: !!config.onUserInputRequest, hooks: !!(config.hooks && Object.values(config.hooks).some(Boolean)), workingDirectory: config.workingDirectory, configDir: config.configDir, streaming: config.streaming, mcpServers: config.mcpServers, + envValueMode: "direct", customAgents: config.customAgents, skillDirectories: config.skillDirectories, disabledSkills: config.disabledSkills, @@ -771,7 +808,15 @@ export class CopilotClient { */ private async verifyProtocolVersion(): Promise { const expectedVersion = getSdkProtocolVersion(); - const pingResult = await this.ping(); + + // Race ping against process exit to detect early CLI failures + let pingResult: Awaited>; + if (this.processExitPromise) { + pingResult = await Promise.race([this.ping(), this.processExitPromise]); + } else { + pingResult = await this.ping(); + } + const serverVersion = pingResult.protocolVersion; if (serverVersion === undefined) { @@ -849,27 +894,24 @@ export class CopilotClient { } /** - * Lists all available sessions known to the server. - * - * Returns metadata about each session including ID, timestamps, and summary. + * List all available sessions. * - * @returns A promise that resolves with an array of session metadata - * @throws Error if the client is not connected + * @param filter - Optional filter to limit returned sessions by context fields * * @example - * ```typescript + * // List all sessions * const sessions = await client.listSessions(); - * for (const session of sessions) { - * console.log(`${session.sessionId}: ${session.summary}`); - * } - * ``` + * + * @example + * // List sessions for a specific repository + * const sessions = await client.listSessions({ repository: "owner/repo" }); */ - async listSessions(): Promise { + async listSessions(filter?: SessionListFilter): Promise { if (!this.connection) { throw new Error("Client not connected"); } - const response = await this.connection.sendRequest("session.list", {}); + const response = await this.connection.sendRequest("session.list", { filter }); const { sessions } = response as { sessions: Array<{ sessionId: string; @@ -877,6 +919,7 @@ export class CopilotClient { modifiedTime: string; summary?: string; isRemote: boolean; + context?: SessionContext; }>; }; @@ -886,6 +929,7 @@ export class CopilotClient { modifiedTime: new Date(s.modifiedTime), summary: s.summary, isRemote: s.isRemote, + context: s.context, })); } @@ -1028,6 +1072,9 @@ export class CopilotClient { */ private async startCLIServer(): Promise { return new Promise((resolve, reject) => { + // Clear stderr buffer for fresh capture + this.stderrBuffer = ""; + const args = [ ...this.options.cliArgs, "--headless", @@ -1075,16 +1122,18 @@ export class CopilotClient { // For .js files, spawn node explicitly; for executables, spawn directly const isJsFile = this.options.cliPath.endsWith(".js"); if (isJsFile) { - this.cliProcess = spawn(process.execPath, [this.options.cliPath, ...args], { + this.cliProcess = spawn(getNodeExecPath(), [this.options.cliPath, ...args], { stdio: stdioConfig, cwd: this.options.cwd, env: envWithoutNodeDebug, + windowsHide: true, }); } else { this.cliProcess = spawn(this.options.cliPath, args, { stdio: stdioConfig, cwd: this.options.cwd, env: envWithoutNodeDebug, + windowsHide: true, }); } @@ -1109,6 +1158,8 @@ export class CopilotClient { } this.cliProcess.stderr?.on("data", (data: Buffer) => { + // Capture stderr for error messages + this.stderrBuffer += data.toString(); // Forward CLI stderr to parent's stderr so debug logs are visible const lines = data.toString().split("\n"); for (const line of lines) { @@ -1121,14 +1172,55 @@ export class CopilotClient { this.cliProcess.on("error", (error) => { if (!resolved) { resolved = true; - reject(new Error(`Failed to start CLI server: ${error.message}`)); + const stderrOutput = this.stderrBuffer.trim(); + if (stderrOutput) { + reject( + new Error( + `Failed to start CLI server: ${error.message}\nstderr: ${stderrOutput}` + ) + ); + } else { + reject(new Error(`Failed to start CLI server: ${error.message}`)); + } } }); + // Set up a promise that rejects when the process exits (used to race against RPC calls) + this.processExitPromise = new Promise((_, rejectProcessExit) => { + this.cliProcess!.on("exit", (code) => { + // Give a small delay for stderr to be fully captured + setTimeout(() => { + const stderrOutput = this.stderrBuffer.trim(); + if (stderrOutput) { + rejectProcessExit( + new Error( + `CLI server exited with code ${code}\nstderr: ${stderrOutput}` + ) + ); + } else { + rejectProcessExit( + new Error(`CLI server exited unexpectedly with code ${code}`) + ); + } + }, 50); + }); + }); + // Prevent unhandled rejection when process exits normally (we only use this in Promise.race) + this.processExitPromise.catch(() => {}); + this.cliProcess.on("exit", (code) => { if (!resolved) { resolved = true; - reject(new Error(`CLI server exited with code ${code}`)); + const stderrOutput = this.stderrBuffer.trim(); + if (stderrOutput) { + reject( + new Error( + `CLI server exited with code ${code}\nstderr: ${stderrOutput}` + ) + ); + } else { + reject(new Error(`CLI server exited with code ${code}`)); + } } else if (this.options.autoRestart && this.state === "connected") { void this.reconnect(); } diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts new file mode 100644 index 000000000..12c992bd6 --- /dev/null +++ b/nodejs/src/generated/rpc.ts @@ -0,0 +1,373 @@ +/** + * AUTO-GENERATED FILE - DO NOT EDIT + * Generated from: api.schema.json + */ + +import type { MessageConnection } from "vscode-jsonrpc/node.js"; + +export interface PingResult { + /** + * Echoed message (or default greeting) + */ + message: string; + /** + * Server timestamp in milliseconds + */ + timestamp: number; + /** + * Server protocol version number + */ + protocolVersion: number; +} + +export interface PingParams { + /** + * Optional message to echo back + */ + message?: string; +} + +export interface ModelsListResult { + /** + * List of available models with full metadata + */ + models: { + /** + * Model identifier (e.g., "claude-sonnet-4.5") + */ + id: string; + /** + * Display name + */ + name: string; + /** + * Model capabilities and limits + */ + capabilities: { + supports: { + vision: boolean; + /** + * Whether this model supports reasoning effort configuration + */ + reasoningEffort: boolean; + }; + limits: { + max_prompt_tokens?: number; + max_output_tokens?: number; + max_context_window_tokens: number; + }; + }; + /** + * Policy state (if applicable) + */ + policy?: { + state: string; + terms: string; + }; + /** + * Billing information + */ + billing?: { + multiplier: number; + }; + /** + * Supported reasoning effort levels (only present if model supports reasoning effort) + */ + supportedReasoningEfforts?: string[]; + /** + * Default reasoning effort level (only present if model supports reasoning effort) + */ + defaultReasoningEffort?: string; + }[]; +} + +export interface ToolsListResult { + /** + * List of available built-in tools with metadata + */ + tools: { + /** + * Tool identifier (e.g., "bash", "grep", "str_replace_editor") + */ + name: string; + /** + * Optional namespaced name for declarative filtering (e.g., "playwright/navigate" for MCP tools) + */ + namespacedName?: string; + /** + * Description of what the tool does + */ + description: string; + /** + * JSON Schema for the tool's input parameters + */ + parameters?: { + [k: string]: unknown; + }; + /** + * Optional instructions for how to use this tool effectively + */ + instructions?: string; + }[]; +} + +export interface ToolsListParams { + /** + * Optional model ID — when provided, the returned tool list reflects model-specific overrides + */ + model?: string; +} + +export interface AccountGetQuotaResult { + /** + * Quota snapshots keyed by type (e.g., chat, completions, premium_interactions) + */ + quotaSnapshots: { + [k: string]: { + /** + * Number of requests included in the entitlement + */ + entitlementRequests: number; + /** + * Number of requests used so far this period + */ + usedRequests: number; + /** + * Percentage of entitlement remaining + */ + remainingPercentage: number; + /** + * Number of overage requests made this period + */ + overage: number; + /** + * Whether pay-per-request usage is allowed when quota is exhausted + */ + overageAllowedWithExhaustedQuota: boolean; + /** + * Date when the quota resets (ISO 8601) + */ + resetDate?: string; + }; + }; +} + +export interface SessionModelGetCurrentResult { + modelId?: string; +} + +export interface SessionModelGetCurrentParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionModelSwitchToResult { + modelId?: string; +} + +export interface SessionModelSwitchToParams { + /** + * Target session identifier + */ + sessionId: string; + modelId: string; +} + +export interface SessionModeGetResult { + /** + * The current agent mode. + */ + mode: "interactive" | "plan" | "autopilot"; +} + +export interface SessionModeGetParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionModeSetResult { + /** + * The agent mode after switching. + */ + mode: "interactive" | "plan" | "autopilot"; +} + +export interface SessionModeSetParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * The mode to switch to. Valid values: "interactive", "plan", "autopilot". + */ + mode: "interactive" | "plan" | "autopilot"; +} + +export interface SessionPlanReadResult { + /** + * Whether plan.md exists in the workspace + */ + exists: boolean; + /** + * The content of plan.md, or null if it does not exist + */ + content: string | null; +} + +export interface SessionPlanReadParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionPlanUpdateResult {} + +export interface SessionPlanUpdateParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * The new content for plan.md + */ + content: string; +} + +export interface SessionPlanDeleteResult {} + +export interface SessionPlanDeleteParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionWorkspaceListFilesResult { + /** + * Relative file paths in the workspace files directory + */ + files: string[]; +} + +export interface SessionWorkspaceListFilesParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionWorkspaceReadFileResult { + /** + * File content as a UTF-8 string + */ + content: string; +} + +export interface SessionWorkspaceReadFileParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Relative path within the workspace files directory + */ + path: string; +} + +export interface SessionWorkspaceCreateFileResult {} + +export interface SessionWorkspaceCreateFileParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Relative path within the workspace files directory + */ + path: string; + /** + * File content to write as a UTF-8 string + */ + content: string; +} + +export interface SessionFleetStartResult { + /** + * Whether fleet mode was successfully activated + */ + started: boolean; +} + +export interface SessionFleetStartParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Optional user prompt to combine with fleet instructions + */ + prompt?: string; +} + +/** Create typed server-scoped RPC methods (no session required). */ +export function createServerRpc(connection: MessageConnection) { + return { + ping: async (params: PingParams): Promise => + connection.sendRequest("ping", params), + models: { + list: async (): Promise => + connection.sendRequest("models.list", {}), + }, + tools: { + list: async (params: ToolsListParams): Promise => + connection.sendRequest("tools.list", params), + }, + account: { + getQuota: async (): Promise => + connection.sendRequest("account.getQuota", {}), + }, + }; +} + +/** Create typed session-scoped RPC methods. */ +export function createSessionRpc(connection: MessageConnection, sessionId: string) { + return { + model: { + getCurrent: async (): Promise => + connection.sendRequest("session.model.getCurrent", { sessionId }), + switchTo: async (params: Omit): Promise => + connection.sendRequest("session.model.switchTo", { sessionId, ...params }), + }, + mode: { + get: async (): Promise => + connection.sendRequest("session.mode.get", { sessionId }), + set: async (params: Omit): Promise => + connection.sendRequest("session.mode.set", { sessionId, ...params }), + }, + plan: { + read: async (): Promise => + connection.sendRequest("session.plan.read", { sessionId }), + update: async (params: Omit): Promise => + connection.sendRequest("session.plan.update", { sessionId, ...params }), + delete: async (): Promise => + connection.sendRequest("session.plan.delete", { sessionId }), + }, + workspace: { + listFiles: async (): Promise => + connection.sendRequest("session.workspace.listFiles", { sessionId }), + readFile: async (params: Omit): Promise => + connection.sendRequest("session.workspace.readFile", { sessionId, ...params }), + createFile: async (params: Omit): Promise => + connection.sendRequest("session.workspace.createFile", { sessionId, ...params }), + }, + fleet: { + start: async (params: Omit): Promise => + connection.sendRequest("session.fleet.start", { sessionId, ...params }), + }, + }; +} diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 86783a043..032a1723d 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -1,13 +1,6 @@ /** * AUTO-GENERATED FILE - DO NOT EDIT - * - * Generated from: @github/copilot/session-events.schema.json - * Generated by: scripts/generate-session-types.ts - * Generated at: 2026-02-06T20:38:23.139Z - * - * To update these types: - * 1. Update the schema in copilot-agent-runtime - * 2. Run: npm run generate:session-types + * Generated from: session-events.schema.json */ export type SessionEvent = @@ -71,6 +64,16 @@ export type SessionEvent = type: "session.idle"; data: {}; } + | { + id: string; + timestamp: string; + parentId: string | null; + ephemeral: true; + type: "session.title_changed"; + data: { + title: string; + }; + } | { id: string; timestamp: string; @@ -82,6 +85,17 @@ export type SessionEvent = message: string; }; } + | { + id: string; + timestamp: string; + parentId: string | null; + ephemeral?: boolean; + type: "session.warning"; + data: { + warningType: string; + message: string; + }; + } | { id: string; timestamp: string; @@ -93,6 +107,41 @@ export type SessionEvent = newModel: string; }; } + | { + id: string; + timestamp: string; + parentId: string | null; + ephemeral?: boolean; + type: "session.mode_changed"; + data: { + previousMode: string; + newMode: string; + }; + } + | { + id: string; + timestamp: string; + parentId: string | null; + ephemeral?: boolean; + type: "session.plan_changed"; + data: { + operation: "create" | "update" | "delete"; + }; + } + | { + id: string; + timestamp: string; + parentId: string | null; + ephemeral?: boolean; + type: "session.workspace_file_changed"; + data: { + /** + * Relative path within the workspace files directory + */ + path: string; + operation: "create" | "update"; + }; + } | { id: string; timestamp: string; @@ -174,6 +223,19 @@ export type SessionEvent = currentModel?: string; }; } + | { + id: string; + timestamp: string; + parentId: string | null; + ephemeral?: boolean; + type: "session.context_changed"; + data: { + cwd: string; + gitRoot?: string; + repository?: string; + branch?: string; + }; + } | { id: string; timestamp: string; @@ -233,11 +295,19 @@ export type SessionEvent = type: "file"; path: string; displayName: string; + lineRange?: { + start: number; + end: number; + }; } | { type: "directory"; path: string; displayName: string; + lineRange?: { + start: number; + end: number; + }; } | { type: "selection"; @@ -257,6 +327,7 @@ export type SessionEvent = } )[]; source?: string; + agentMode?: "interactive" | "plan" | "autopilot" | "shell"; }; } | { @@ -327,6 +398,7 @@ export type SessionEvent = reasoningOpaque?: string; reasoningText?: string; encryptedContent?: string; + phase?: string; parentToolCallId?: string; }; } @@ -457,6 +529,57 @@ export type SessionEvent = result?: { content: string; detailedContent?: string; + contents?: ( + | { + type: "text"; + text: string; + } + | { + type: "terminal"; + text: string; + exitCode?: number; + cwd?: string; + } + | { + type: "image"; + data: string; + mimeType: string; + } + | { + type: "audio"; + data: string; + mimeType: string; + } + | { + icons?: { + src: string; + mimeType?: string; + sizes?: string[]; + theme?: "light" | "dark"; + }[]; + name: string; + title?: string; + uri: string; + description?: string; + mimeType?: string; + size?: number; + type: "resource_link"; + } + | { + type: "resource"; + resource: + | { + uri: string; + mimeType?: string; + text: string; + } + | { + uri: string; + mimeType?: string; + blob: string; + }; + } + )[]; }; error?: { message: string; @@ -503,6 +626,7 @@ export type SessionEvent = data: { toolCallId: string; agentName: string; + agentDisplayName: string; }; } | { @@ -514,6 +638,7 @@ export type SessionEvent = data: { toolCallId: string; agentName: string; + agentDisplayName: string; error: string; }; } diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 4f9fcbf6a..f2655f2fc 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -10,7 +10,7 @@ export { CopilotClient } from "./client.js"; export { CopilotSession, type AssistantMessageEvent } from "./session.js"; -export { defineTool } from "./types.js"; +export { defineTool, approveAll } from "./types.js"; export type { ConnectionState, CopilotClientOptions, @@ -39,6 +39,8 @@ export type { SessionLifecycleEvent, SessionLifecycleEventType, SessionLifecycleHandler, + SessionContext, + SessionListFilter, SessionMetadata, SystemMessageAppendConfig, SystemMessageConfig, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 19da9bd10..04525d2bb 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -8,6 +8,7 @@ */ import type { MessageConnection } from "vscode-jsonrpc/node"; +import { createSessionRpc } from "./generated/rpc.js"; import type { MessageOptions, PermissionHandler, @@ -62,6 +63,7 @@ export class CopilotSession { private permissionHandler?: PermissionHandler; private userInputHandler?: UserInputHandler; private hooks?: SessionHooks; + private _rpc: ReturnType | null = null; /** * Creates a new CopilotSession instance. @@ -77,6 +79,16 @@ export class CopilotSession { private readonly _workspacePath?: string ) {} + /** + * Typed session-scoped RPC methods. + */ + get rpc(): ReturnType { + if (!this._rpc) { + this._rpc = createSessionRpc(this.connection, this.sessionId); + } + return this._rpc; + } + /** * Path to the session workspace directory when infinite sessions are enabled. * Contains checkpoints/, plan.md, and files/ subdirectories. diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 9f04f895a..6ed46969f 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -238,6 +238,8 @@ export type PermissionHandler = ( invocation: { sessionId: string } ) => Promise | PermissionRequestResult; +export const approveAll: PermissionHandler = () => ({ kind: "approved" }); + // ============================================================================ // User Input Request Types // ============================================================================ @@ -623,6 +625,12 @@ export interface SessionConfig { */ sessionId?: string; + /** + * Client name to identify the application using the SDK. + * Included in the User-Agent header for API requests. + */ + clientName?: string; + /** * Model to use for this session */ @@ -738,6 +746,7 @@ export interface SessionConfig { */ export type ResumeSessionConfig = Pick< SessionConfig, + | "clientName" | "model" | "tools" | "systemMessage" @@ -877,6 +886,34 @@ export type SessionEventHandler = (event: SessionEvent) => void; */ export type ConnectionState = "disconnected" | "connecting" | "connected" | "error"; +/** + * Working directory context for a session + */ +export interface SessionContext { + /** Working directory where the session was created */ + cwd: string; + /** Git repository root (if in a git repo) */ + gitRoot?: string; + /** GitHub repository in "owner/repo" format */ + repository?: string; + /** Current git branch */ + branch?: string; +} + +/** + * Filter options for listing sessions + */ +export interface SessionListFilter { + /** Filter by exact cwd match */ + cwd?: string; + /** Filter by git root */ + gitRoot?: string; + /** Filter by repository (owner/repo format) */ + repository?: string; + /** Filter by branch */ + branch?: string; +} + /** * Metadata about a session */ @@ -886,6 +923,8 @@ export interface SessionMetadata { modifiedTime: Date; summary?: string; isRemote: boolean; + /** Working directory context (cwd, git info) from session creation */ + context?: SessionContext; } /** diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 25a8fb87d..1ab89e7c2 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { describe, expect, it, onTestFinished } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; import { CopilotClient } from "../src/index.js"; // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead @@ -27,6 +27,35 @@ describe("CopilotClient", () => { }); }); + it("forwards clientName in session.create request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ clientName: "my-app" }); + + expect(spy).toHaveBeenCalledWith( + "session.create", + expect.objectContaining({ clientName: "my-app" }) + ); + }); + + it("forwards clientName in session.resume request", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession(); + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.resumeSession(session.sessionId, { clientName: "my-app" }); + + expect(spy).toHaveBeenCalledWith( + "session.resume", + expect.objectContaining({ clientName: "my-app", sessionId: session.sessionId }) + ); + }); + describe("URL parsing", () => { it("should parse port-only URL format", () => { const client = new CopilotClient({ diff --git a/nodejs/test/e2e/client.test.ts b/nodejs/test/e2e/client.test.ts index 526e95095..aa8ddcbd6 100644 --- a/nodejs/test/e2e/client.test.ts +++ b/nodejs/test/e2e/client.test.ts @@ -132,4 +132,31 @@ describe("Client", () => { await client.stop(); }); + + it("should report error with stderr when CLI fails to start", async () => { + const client = new CopilotClient({ + cliArgs: ["--nonexistent-flag-for-testing"], + useStdio: true, + }); + onTestFinishedForceStop(client); + + let initialError: Error | undefined; + try { + await client.start(); + expect.fail("Expected start() to throw an error"); + } catch (error) { + initialError = error as Error; + expect(initialError.message).toContain("stderr"); + expect(initialError.message).toContain("nonexistent"); + } + + // Verify subsequent calls also fail (don't hang) + try { + const session = await client.createSession(); + await session.send("test"); + expect.fail("Expected send() to throw an error after CLI exit"); + } catch (error) { + expect((error as Error).message).toContain("Connection is closed"); + } + }); }); diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index beabf3812..4986d1299 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -19,7 +19,7 @@ const SNAPSHOTS_DIR = resolve(__dirname, "../../../../test/snapshots"); export async function createSdkTestContext({ logLevel, -}: { logLevel?: "error" | "none" | "warning" | "info" | "debug" | "all" } = {}) { +}: { logLevel?: "error" | "none" | "warning" | "info" | "debug" | "all"; cliPath?: string } = {}) { const homeDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-test-config-"))); const workDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-test-work-"))); @@ -40,6 +40,7 @@ export async function createSdkTestContext({ cwd: workDir, env, logLevel: logLevel || "error", + cliPath: process.env.COPILOT_CLI_PATH, // Use fake token in CI to allow cached responses without real auth githubToken: process.env.CI === "true" ? "fake-token-for-e2e-tests" : undefined, }); diff --git a/nodejs/test/e2e/hooks.test.ts b/nodejs/test/e2e/hooks.test.ts index 0a91f466f..18cc9fea0 100644 --- a/nodejs/test/e2e/hooks.test.ts +++ b/nodejs/test/e2e/hooks.test.ts @@ -11,6 +11,7 @@ import type { PostToolUseHookInput, PostToolUseHookOutput, } from "../../src/index.js"; +import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("Session hooks", async () => { @@ -20,6 +21,7 @@ describe("Session hooks", async () => { const preToolUseInputs: PreToolUseHookInput[] = []; const session = await client.createSession({ + onPermissionRequest: approveAll, hooks: { onPreToolUse: async (input, invocation) => { preToolUseInputs.push(input); @@ -50,6 +52,7 @@ describe("Session hooks", async () => { const postToolUseInputs: PostToolUseHookInput[] = []; const session = await client.createSession({ + onPermissionRequest: approveAll, hooks: { onPostToolUse: async (input, invocation) => { postToolUseInputs.push(input); @@ -81,6 +84,7 @@ describe("Session hooks", async () => { const postToolUseInputs: PostToolUseHookInput[] = []; const session = await client.createSession({ + onPermissionRequest: approveAll, hooks: { onPreToolUse: async (input) => { preToolUseInputs.push(input); diff --git a/nodejs/test/e2e/mcp_and_agents.test.ts b/nodejs/test/e2e/mcp_and_agents.test.ts index 49047a0da..7b7aabf06 100644 --- a/nodejs/test/e2e/mcp_and_agents.test.ts +++ b/nodejs/test/e2e/mcp_and_agents.test.ts @@ -2,10 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; import { describe, expect, it } from "vitest"; import type { CustomAgentConfig, MCPLocalServerConfig, MCPServerConfig } from "../../src/index.js"; +import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const TEST_MCP_SERVER = resolve(__dirname, "../../../test/harness/test-mcp-server.mjs"); + describe("MCP Servers and Custom Agents", async () => { const { copilotClient: client } = await createSdkTestContext(); @@ -88,6 +95,32 @@ describe("MCP Servers and Custom Agents", async () => { expect(session.sessionId).toBeDefined(); await session.destroy(); }); + + it("should pass literal env values to MCP server subprocess", async () => { + const mcpServers: Record = { + "env-echo": { + type: "local", + command: "node", + args: [TEST_MCP_SERVER], + tools: ["*"], + env: { TEST_SECRET: "hunter2" }, + } as MCPLocalServerConfig, + }; + + const session = await client.createSession({ + mcpServers, + onPermissionRequest: approveAll, + }); + + expect(session.sessionId).toBeDefined(); + + const message = await session.sendAndWait({ + prompt: "Use the env-echo/get_env tool to read the TEST_SECRET environment variable. Reply with just the value, nothing else.", + }); + expect(message?.data.content).toContain("hunter2"); + + await session.destroy(); + }); }); describe("Custom Agents", () => { diff --git a/nodejs/test/e2e/permissions.test.ts b/nodejs/test/e2e/permissions.test.ts index 91bad2b03..b68446ee9 100644 --- a/nodejs/test/e2e/permissions.test.ts +++ b/nodejs/test/e2e/permissions.test.ts @@ -6,6 +6,7 @@ import { readFile, writeFile } from "fs/promises"; import { join } from "path"; import { describe, expect, it } from "vitest"; import type { PermissionRequest, PermissionRequestResult } from "../../src/index.js"; +import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("Permission callbacks", async () => { @@ -63,6 +64,51 @@ describe("Permission callbacks", async () => { await session.destroy(); }); + it("should deny tool operations by default when no handler is provided", async () => { + let permissionDenied = false; + + const session = await client.createSession(); + session.on((event) => { + if ( + event.type === "tool.execution_complete" && + !event.data.success && + event.data.error?.message.includes("Permission denied") + ) { + permissionDenied = true; + } + }); + + await session.sendAndWait({ prompt: "Run 'node --version'" }); + + expect(permissionDenied).toBe(true); + + await session.destroy(); + }); + + it("should deny tool operations by default when no handler is provided after resume", async () => { + const session1 = await client.createSession({ onPermissionRequest: approveAll }); + const sessionId = session1.sessionId; + await session1.sendAndWait({ prompt: "What is 1+1?" }); + + const session2 = await client.resumeSession(sessionId); + let permissionDenied = false; + session2.on((event) => { + if ( + event.type === "tool.execution_complete" && + !event.data.success && + event.data.error?.message.includes("Permission denied") + ) { + permissionDenied = true; + } + }); + + await session2.sendAndWait({ prompt: "Run 'node --version'" }); + + expect(permissionDenied).toBe(true); + + await session2.destroy(); + }); + it("should work without permission handler (default behavior)", async () => { // Create session without onPermissionRequest handler const session = await client.createSession(); diff --git a/nodejs/test/e2e/rpc.test.ts b/nodejs/test/e2e/rpc.test.ts new file mode 100644 index 000000000..b7acbaf66 --- /dev/null +++ b/nodejs/test/e2e/rpc.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, onTestFinished } from "vitest"; +import { CopilotClient } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +function onTestFinishedForceStop(client: CopilotClient) { + onTestFinished(async () => { + try { + await client.forceStop(); + } catch { + // Ignore cleanup errors - process may already be stopped + } + }); +} + +describe("RPC", () => { + it("should call rpc.ping with typed params and result", async () => { + const client = new CopilotClient({ useStdio: true }); + onTestFinishedForceStop(client); + + await client.start(); + + const result = await client.rpc.ping({ message: "typed rpc test" }); + expect(result.message).toBe("pong: typed rpc test"); + expect(typeof result.timestamp).toBe("number"); + + await client.stop(); + }); + + it("should call rpc.models.list with typed result", async () => { + const client = new CopilotClient({ useStdio: true }); + onTestFinishedForceStop(client); + + await client.start(); + + const authStatus = await client.getAuthStatus(); + if (!authStatus.isAuthenticated) { + await client.stop(); + return; + } + + const result = await client.rpc.models.list(); + expect(result.models).toBeDefined(); + expect(Array.isArray(result.models)).toBe(true); + + await client.stop(); + }); + + // account.getQuota is defined in schema but not yet implemented in CLI + it.skip("should call rpc.account.getQuota when authenticated", async () => { + const client = new CopilotClient({ useStdio: true }); + onTestFinishedForceStop(client); + + await client.start(); + + const authStatus = await client.getAuthStatus(); + if (!authStatus.isAuthenticated) { + await client.stop(); + return; + } + + const result = await client.rpc.account.getQuota(); + expect(result.quotaSnapshots).toBeDefined(); + expect(typeof result.quotaSnapshots).toBe("object"); + + await client.stop(); + }); +}); + +describe("Session RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + // session.model.getCurrent is defined in schema but not yet implemented in CLI + it.skip("should call session.rpc.model.getCurrent", async () => { + const session = await client.createSession({ model: "claude-sonnet-4.5" }); + + const result = await session.rpc.model.getCurrent(); + expect(result.modelId).toBeDefined(); + expect(typeof result.modelId).toBe("string"); + }); + + // session.model.switchTo is defined in schema but not yet implemented in CLI + it.skip("should call session.rpc.model.switchTo", async () => { + const session = await client.createSession({ model: "claude-sonnet-4.5" }); + + // Get initial model + const before = await session.rpc.model.getCurrent(); + expect(before.modelId).toBeDefined(); + + // Switch to a different model + const result = await session.rpc.model.switchTo({ modelId: "gpt-4.1" }); + expect(result.modelId).toBe("gpt-4.1"); + + // Verify the switch persisted + const after = await session.rpc.model.getCurrent(); + expect(after.modelId).toBe("gpt-4.1"); + }); + + it("should get and set session mode", async () => { + const session = await client.createSession(); + + // Get initial mode (default should be interactive) + const initial = await session.rpc.mode.get(); + expect(initial.mode).toBe("interactive"); + + // Switch to plan mode + const planResult = await session.rpc.mode.set({ mode: "plan" }); + expect(planResult.mode).toBe("plan"); + + // Verify mode persisted + const afterPlan = await session.rpc.mode.get(); + expect(afterPlan.mode).toBe("plan"); + + // Switch back to interactive + const interactiveResult = await session.rpc.mode.set({ mode: "interactive" }); + expect(interactiveResult.mode).toBe("interactive"); + }); + + it("should read, update, and delete plan", async () => { + const session = await client.createSession(); + + // Initially plan should not exist + const initial = await session.rpc.plan.read(); + expect(initial.exists).toBe(false); + expect(initial.content).toBeNull(); + + // Create/update plan + const planContent = "# Test Plan\n\n- Step 1\n- Step 2"; + await session.rpc.plan.update({ content: planContent }); + + // Verify plan exists and has correct content + const afterUpdate = await session.rpc.plan.read(); + expect(afterUpdate.exists).toBe(true); + expect(afterUpdate.content).toBe(planContent); + + // Delete plan + await session.rpc.plan.delete(); + + // Verify plan is deleted + const afterDelete = await session.rpc.plan.read(); + expect(afterDelete.exists).toBe(false); + expect(afterDelete.content).toBeNull(); + }); + + it("should create, list, and read workspace files", async () => { + const session = await client.createSession(); + + // Initially no files + const initialFiles = await session.rpc.workspace.listFiles(); + expect(initialFiles.files).toEqual([]); + + // Create a file + const fileContent = "Hello, workspace!"; + await session.rpc.workspace.createFile({ path: "test.txt", content: fileContent }); + + // List files + const afterCreate = await session.rpc.workspace.listFiles(); + expect(afterCreate.files).toContain("test.txt"); + + // Read file + const readResult = await session.rpc.workspace.readFile({ path: "test.txt" }); + expect(readResult.content).toBe(fileContent); + + // Create nested file + await session.rpc.workspace.createFile({ + path: "subdir/nested.txt", + content: "Nested content", + }); + + const afterNested = await session.rpc.workspace.listFiles(); + expect(afterNested.files).toContain("test.txt"); + expect(afterNested.files.some((f) => f.includes("nested.txt"))).toBe(true); + }); +}); diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index 01a3ad0b1..09c293a53 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { ParsedHttpExchange } from "../../../test/harness/replayingCapiProxy.js"; -import { CopilotClient } from "../../src/index.js"; +import { CopilotClient, approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; import { getFinalAssistantMessage, getNextEventOfType } from "./harness/sdkTestHelper.js"; @@ -22,6 +22,27 @@ describe("Sessions", async () => { await expect(() => session.getMessages()).rejects.toThrow(/Session not found/); }); + // TODO: Re-enable once test harness CAPI proxy supports this test's session lifecycle + it.skip("should list sessions with context field", { timeout: 60000 }, async () => { + // Create a session — just creating it is enough for it to appear in listSessions + const session = await client.createSession(); + expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); + + // Verify it has a start event (confirms session is active) + const messages = await session.getMessages(); + expect(messages.length).toBeGreaterThan(0); + + // List sessions and find the one we just created + const sessions = await client.listSessions(); + const ourSession = sessions.find((s) => s.sessionId === session.sessionId); + + expect(ourSession).toBeDefined(); + // Context may not be populated if workspace.yaml hasn't been written yet + if (ourSession?.context) { + expect(ourSession.context.cwd).toMatch(/^(\/|[A-Za-z]:)/); + } + }); + it("should have stateful conversation", async () => { const session = await client.createSession(); const assistantMessage = await session.sendAndWait({ prompt: "What is 1+1?" }); @@ -345,7 +366,9 @@ describe("Send Blocking Behavior", async () => { const { copilotClient: client } = await createSdkTestContext(); it("send returns immediately while events stream in background", async () => { - const session = await client.createSession(); + const session = await client.createSession({ + onPermissionRequest: approveAll, + }); const events: string[] = []; session.on((event) => { diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index 85960b839..3db24dff7 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -6,7 +6,7 @@ import { writeFile } from "fs/promises"; import { join } from "path"; import { assert, describe, expect, it } from "vitest"; import { z } from "zod"; -import { defineTool } from "../../src/index.js"; +import { defineTool, approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext"; describe("Custom tools", async () => { @@ -15,7 +15,9 @@ describe("Custom tools", async () => { it("invokes built-in tools", async () => { await writeFile(join(workDir, "README.md"), "# ELIZA, the only chatbot you'll ever need"); - const session = await client.createSession(); + const session = await client.createSession({ + onPermissionRequest: approveAll, + }); const assistantMessage = await session.sendAndWait({ prompt: "What's the first line of README.md in this directory?", }); diff --git a/python/.gitignore b/python/.gitignore index b9774ce33..8eb101ca3 100644 --- a/python/.gitignore +++ b/python/.gitignore @@ -163,6 +163,9 @@ cython_debug/ .ruff_cache/ .ty_cache/ +# uv +uv.lock + # Build script caches .cli-cache/ .build-temp/ diff --git a/python/README.md b/python/README.md index 7aa11e1ab..aa82e0c34 100644 --- a/python/README.md +++ b/python/README.md @@ -12,6 +12,15 @@ pip install -e ".[dev]" uv pip install -e ".[dev]" ``` +## Run the Sample + +Try the interactive chat sample (from the repo root): + +```bash +cd python/samples +python chat.py +``` + ## Quick Start ```python diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 90a055636..f5f7ed0b1 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -28,7 +28,9 @@ ProviderConfig, ResumeSessionConfig, SessionConfig, + SessionContext, SessionEvent, + SessionListFilter, SessionMetadata, StopError, Tool, @@ -62,7 +64,9 @@ "ProviderConfig", "ResumeSessionConfig", "SessionConfig", + "SessionContext", "SessionEvent", + "SessionListFilter", "SessionMetadata", "StopError", "Tool", diff --git a/python/copilot/client.py b/python/copilot/client.py index 85b728971..90260ffbd 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -23,8 +23,9 @@ from pathlib import Path from typing import Any, Callable, Optional, cast +from .generated.rpc import ServerRpc from .generated.session_events import session_event_from_dict -from .jsonrpc import JsonRpcClient +from .jsonrpc import JsonRpcClient, ProcessExitedError from .sdk_protocol_version import get_sdk_protocol_version from .session import CopilotSession from .types import ( @@ -41,6 +42,7 @@ SessionLifecycleEvent, SessionLifecycleEventType, SessionLifecycleHandler, + SessionListFilter, SessionMetadata, StopError, ToolHandler, @@ -183,6 +185,8 @@ def __init__(self, options: Optional[CopilotClientOptions] = None): "auto_restart": opts.get("auto_restart", True), "use_logged_in_user": use_logged_in_user, } + if opts.get("cli_args"): + self.options["cli_args"] = opts["cli_args"] if opts.get("cli_url"): self.options["cli_url"] = opts["cli_url"] if opts.get("env"): @@ -202,6 +206,14 @@ def __init__(self, options: Optional[CopilotClientOptions] = None): SessionLifecycleEventType, list[SessionLifecycleHandler] ] = {} self._lifecycle_handlers_lock = threading.Lock() + self._rpc: Optional[ServerRpc] = None + + @property + def rpc(self) -> ServerRpc: + """Typed server-scoped RPC methods.""" + if self._rpc is None: + raise RuntimeError("Client is not connected. Call start() first.") + return self._rpc def _parse_cli_url(self, url: str) -> tuple[str, int]: """ @@ -282,8 +294,21 @@ async def start(self) -> None: await self._verify_protocol_version() self._state = "connected" - except Exception: + except ProcessExitedError as e: + # Process exited with error - reraise as RuntimeError with stderr + self._state = "error" + raise RuntimeError(str(e)) from None + except Exception as e: self._state = "error" + # Check if process exited and capture any remaining stderr + if self._process and hasattr(self._process, "poll"): + return_code = self._process.poll() + if return_code is not None and self._client: + stderr_output = self._client.get_stderr_output() + if stderr_output: + raise RuntimeError( + f"CLI process exited with code {return_code}\nstderr: {stderr_output}" + ) from e raise async def stop(self) -> list["StopError"]: @@ -325,6 +350,7 @@ async def stop(self) -> list["StopError"]: if self._client: await self._client.stop() self._client = None + self._rpc = None # Clear models cache async with self._models_cache_lock: @@ -373,6 +399,7 @@ async def force_stop(self) -> None: except Exception: pass # Ignore errors during force stop self._client = None + self._rpc = None # Clear models cache async with self._models_cache_lock: @@ -440,6 +467,8 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo payload["model"] = cfg["model"] if cfg.get("session_id"): payload["sessionId"] = cfg["session_id"] + if cfg.get("client_name"): + payload["clientName"] = cfg["client_name"] if cfg.get("reasoning_effort"): payload["reasoningEffort"] = cfg["reasoning_effort"] if tool_defs: @@ -452,16 +481,15 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo # Add tool filtering options available_tools = cfg.get("available_tools") - if available_tools: + if available_tools is not None: payload["availableTools"] = available_tools excluded_tools = cfg.get("excluded_tools") if excluded_tools: payload["excludedTools"] = excluded_tools - # Enable permission request callback if handler provided + # Always enable permission request callback (deny by default if no handler provided) on_permission_request = cfg.get("on_permission_request") - if on_permission_request: - payload["requestPermission"] = True + payload["requestPermission"] = True # Enable user input request callback if handler provided on_user_input_request = cfg.get("on_user_input_request") @@ -492,6 +520,7 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo mcp_servers = cfg.get("mcp_servers") if mcp_servers: payload["mcpServers"] = mcp_servers + payload["envValueMode"] = "direct" # Add custom agents configuration if provided custom_agents = cfg.get("custom_agents") @@ -601,6 +630,11 @@ async def resume_session( payload: dict[str, Any] = {"sessionId": session_id} + # Add client name if provided + client_name = cfg.get("client_name") + if client_name: + payload["clientName"] = client_name + # Add model if provided model = cfg.get("model") if model: @@ -618,7 +652,7 @@ async def resume_session( # Add available/excluded tools if provided available_tools = cfg.get("available_tools") - if available_tools: + if available_tools is not None: payload["availableTools"] = available_tools excluded_tools = cfg.get("excluded_tools") @@ -634,10 +668,9 @@ async def resume_session( if streaming is not None: payload["streaming"] = streaming - # Enable permission request callback if handler provided + # Always enable permission request callback (deny by default if no handler provided) on_permission_request = cfg.get("on_permission_request") - if on_permission_request: - payload["requestPermission"] = True + payload["requestPermission"] = True # Enable user input request callback if handler provided on_user_input_request = cfg.get("on_user_input_request") @@ -668,6 +701,7 @@ async def resume_session( mcp_servers = cfg.get("mcp_servers") if mcp_servers: payload["mcpServers"] = mcp_servers + payload["envValueMode"] = "direct" # Add custom agents configuration if provided custom_agents = cfg.get("custom_agents") @@ -837,12 +871,18 @@ async def list_models(self) -> list["ModelInfo"]: return list(models) # Return a copy to prevent cache mutation - async def list_sessions(self) -> list["SessionMetadata"]: + async def list_sessions( + self, filter: "SessionListFilter | None" = None + ) -> list["SessionMetadata"]: """ List all available sessions known to the server. Returns metadata about each session including ID, timestamps, and summary. + Args: + filter: Optional filter to narrow down the list of sessions by cwd, git root, + repository, or branch. + Returns: A list of SessionMetadata objects. @@ -853,11 +893,18 @@ async def list_sessions(self) -> list["SessionMetadata"]: >>> sessions = await client.list_sessions() >>> for session in sessions: ... print(f"Session: {session.sessionId}") + >>> # Filter sessions by repository + >>> from copilot import SessionListFilter + >>> filtered = await client.list_sessions(SessionListFilter(repository="owner/repo")) """ if not self._client: raise RuntimeError("Client not connected") - response = await self._client.request("session.list", {}) + payload: dict = {} + if filter is not None: + payload["filter"] = filter.to_dict() + + response = await self._client.request("session.list", payload) sessions_data = response.get("sessions", []) return [SessionMetadata.from_dict(session) for session in sessions_data] @@ -1116,7 +1163,14 @@ async def _start_cli_server(self) -> None: if not os.path.exists(cli_path): raise RuntimeError(f"Copilot CLI not found at {cli_path}") - args = ["--headless", "--no-auto-update", "--log-level", self.options["log_level"]] + # Start with user-provided cli_args, then add SDK-managed args + cli_args = self.options.get("cli_args") or [] + args = list(cli_args) + [ + "--headless", + "--no-auto-update", + "--log-level", + self.options["log_level"], + ] # Add auth-related flags if self.options.get("github_token"): @@ -1142,6 +1196,9 @@ async def _start_cli_server(self) -> None: if self.options.get("github_token"): env["COPILOT_SDK_AUTH_TOKEN"] = self.options["github_token"] + # On Windows, hide the console window to avoid distracting users in GUI apps + creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0 + # Choose transport mode if self.options["use_stdio"]: args.append("--stdio") @@ -1154,6 +1211,7 @@ async def _start_cli_server(self) -> None: bufsize=0, cwd=self.options["cwd"], env=env, + creationflags=creationflags, ) else: if self.options["port"] > 0: @@ -1165,6 +1223,7 @@ async def _start_cli_server(self) -> None: stderr=subprocess.PIPE, cwd=self.options["cwd"], env=env, + creationflags=creationflags, ) # For stdio mode, we're ready immediately @@ -1222,6 +1281,7 @@ async def _connect_via_stdio(self) -> None: # Create JSON-RPC client with the process self._client = JsonRpcClient(self._process) + self._rpc = ServerRpc(self._client) # Set up notification handler for session events # Note: This handler is called from the event loop (thread-safe scheduling) @@ -1304,6 +1364,7 @@ def wait(self, timeout=None): self._process = SocketWrapper(sock_file, sock) # type: ignore self._client = JsonRpcClient(self._process) + self._rpc = ServerRpc(self._client) # Set up notification handler for session events def handle_notification(method: str, params: dict): diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py new file mode 100644 index 000000000..3b87bea55 --- /dev/null +++ b/python/copilot/generated/rpc.py @@ -0,0 +1,1034 @@ +""" +AUTO-GENERATED FILE - DO NOT EDIT +Generated from: api.schema.json +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..jsonrpc import JsonRpcClient + + +from dataclasses import dataclass +from typing import Any, Optional, List, Dict, TypeVar, Type, cast, Callable +from enum import Enum + + +T = TypeVar("T") +EnumT = TypeVar("EnumT", bound=Enum) + + +def from_str(x: Any) -> str: + assert isinstance(x, str) + return x + + +def from_float(x: Any) -> float: + assert isinstance(x, (float, int)) and not isinstance(x, bool) + return float(x) + + +def to_float(x: Any) -> float: + assert isinstance(x, (int, float)) + return x + + +def from_none(x: Any) -> Any: + assert x is None + return x + + +def from_union(fs, x): + for f in fs: + try: + return f(x) + except Exception: + pass + assert False + + +def from_bool(x: Any) -> bool: + assert isinstance(x, bool) + return x + + +def to_class(c: Type[T], x: Any) -> dict: + assert isinstance(x, c) + return cast(Any, x).to_dict() + + +def from_list(f: Callable[[Any], T], x: Any) -> List[T]: + assert isinstance(x, list) + return [f(y) for y in x] + + +def from_dict(f: Callable[[Any], T], x: Any) -> Dict[str, T]: + assert isinstance(x, dict) + return { k: f(v) for (k, v) in x.items() } + + +def to_enum(c: Type[EnumT], x: Any) -> EnumT: + assert isinstance(x, c) + return x.value + + +@dataclass +class PingResult: + message: str + """Echoed message (or default greeting)""" + + protocol_version: float + """Server protocol version number""" + + timestamp: float + """Server timestamp in milliseconds""" + + @staticmethod + def from_dict(obj: Any) -> 'PingResult': + assert isinstance(obj, dict) + message = from_str(obj.get("message")) + protocol_version = from_float(obj.get("protocolVersion")) + timestamp = from_float(obj.get("timestamp")) + return PingResult(message, protocol_version, timestamp) + + def to_dict(self) -> dict: + result: dict = {} + result["message"] = from_str(self.message) + result["protocolVersion"] = to_float(self.protocol_version) + result["timestamp"] = to_float(self.timestamp) + return result + + +@dataclass +class PingParams: + message: Optional[str] = None + """Optional message to echo back""" + + @staticmethod + def from_dict(obj: Any) -> 'PingParams': + assert isinstance(obj, dict) + message = from_union([from_str, from_none], obj.get("message")) + return PingParams(message) + + def to_dict(self) -> dict: + result: dict = {} + if self.message is not None: + result["message"] = from_union([from_str, from_none], self.message) + return result + + +@dataclass +class Billing: + """Billing information""" + + multiplier: float + + @staticmethod + def from_dict(obj: Any) -> 'Billing': + assert isinstance(obj, dict) + multiplier = from_float(obj.get("multiplier")) + return Billing(multiplier) + + def to_dict(self) -> dict: + result: dict = {} + result["multiplier"] = to_float(self.multiplier) + return result + + +@dataclass +class Limits: + max_context_window_tokens: float + max_output_tokens: Optional[float] = None + max_prompt_tokens: Optional[float] = None + + @staticmethod + def from_dict(obj: Any) -> 'Limits': + assert isinstance(obj, dict) + max_context_window_tokens = from_float(obj.get("max_context_window_tokens")) + max_output_tokens = from_union([from_float, from_none], obj.get("max_output_tokens")) + max_prompt_tokens = from_union([from_float, from_none], obj.get("max_prompt_tokens")) + return Limits(max_context_window_tokens, max_output_tokens, max_prompt_tokens) + + def to_dict(self) -> dict: + result: dict = {} + result["max_context_window_tokens"] = to_float(self.max_context_window_tokens) + if self.max_output_tokens is not None: + result["max_output_tokens"] = from_union([to_float, from_none], self.max_output_tokens) + if self.max_prompt_tokens is not None: + result["max_prompt_tokens"] = from_union([to_float, from_none], self.max_prompt_tokens) + return result + + +@dataclass +class Supports: + reasoning_effort: bool + """Whether this model supports reasoning effort configuration""" + + vision: bool + + @staticmethod + def from_dict(obj: Any) -> 'Supports': + assert isinstance(obj, dict) + reasoning_effort = from_bool(obj.get("reasoningEffort")) + vision = from_bool(obj.get("vision")) + return Supports(reasoning_effort, vision) + + def to_dict(self) -> dict: + result: dict = {} + result["reasoningEffort"] = from_bool(self.reasoning_effort) + result["vision"] = from_bool(self.vision) + return result + + +@dataclass +class Capabilities: + """Model capabilities and limits""" + + limits: Limits + supports: Supports + + @staticmethod + def from_dict(obj: Any) -> 'Capabilities': + assert isinstance(obj, dict) + limits = Limits.from_dict(obj.get("limits")) + supports = Supports.from_dict(obj.get("supports")) + return Capabilities(limits, supports) + + def to_dict(self) -> dict: + result: dict = {} + result["limits"] = to_class(Limits, self.limits) + result["supports"] = to_class(Supports, self.supports) + return result + + +@dataclass +class Policy: + """Policy state (if applicable)""" + + state: str + terms: str + + @staticmethod + def from_dict(obj: Any) -> 'Policy': + assert isinstance(obj, dict) + state = from_str(obj.get("state")) + terms = from_str(obj.get("terms")) + return Policy(state, terms) + + def to_dict(self) -> dict: + result: dict = {} + result["state"] = from_str(self.state) + result["terms"] = from_str(self.terms) + return result + + +@dataclass +class Model: + capabilities: Capabilities + """Model capabilities and limits""" + + id: str + """Model identifier (e.g., "claude-sonnet-4.5")""" + + name: str + """Display name""" + + billing: Optional[Billing] = None + """Billing information""" + + default_reasoning_effort: Optional[str] = None + """Default reasoning effort level (only present if model supports reasoning effort)""" + + policy: Optional[Policy] = None + """Policy state (if applicable)""" + + supported_reasoning_efforts: Optional[List[str]] = None + """Supported reasoning effort levels (only present if model supports reasoning effort)""" + + @staticmethod + def from_dict(obj: Any) -> 'Model': + assert isinstance(obj, dict) + capabilities = Capabilities.from_dict(obj.get("capabilities")) + id = from_str(obj.get("id")) + name = from_str(obj.get("name")) + billing = from_union([Billing.from_dict, from_none], obj.get("billing")) + default_reasoning_effort = from_union([from_str, from_none], obj.get("defaultReasoningEffort")) + policy = from_union([Policy.from_dict, from_none], obj.get("policy")) + supported_reasoning_efforts = from_union([lambda x: from_list(from_str, x), from_none], obj.get("supportedReasoningEfforts")) + return Model(capabilities, id, name, billing, default_reasoning_effort, policy, supported_reasoning_efforts) + + def to_dict(self) -> dict: + result: dict = {} + result["capabilities"] = to_class(Capabilities, self.capabilities) + result["id"] = from_str(self.id) + result["name"] = from_str(self.name) + if self.billing is not None: + result["billing"] = from_union([lambda x: to_class(Billing, x), from_none], self.billing) + if self.default_reasoning_effort is not None: + result["defaultReasoningEffort"] = from_union([from_str, from_none], self.default_reasoning_effort) + if self.policy is not None: + result["policy"] = from_union([lambda x: to_class(Policy, x), from_none], self.policy) + if self.supported_reasoning_efforts is not None: + result["supportedReasoningEfforts"] = from_union([lambda x: from_list(from_str, x), from_none], self.supported_reasoning_efforts) + return result + + +@dataclass +class ModelsListResult: + models: List[Model] + """List of available models with full metadata""" + + @staticmethod + def from_dict(obj: Any) -> 'ModelsListResult': + assert isinstance(obj, dict) + models = from_list(Model.from_dict, obj.get("models")) + return ModelsListResult(models) + + def to_dict(self) -> dict: + result: dict = {} + result["models"] = from_list(lambda x: to_class(Model, x), self.models) + return result + + +@dataclass +class Tool: + description: str + """Description of what the tool does""" + + name: str + """Tool identifier (e.g., "bash", "grep", "str_replace_editor")""" + + instructions: Optional[str] = None + """Optional instructions for how to use this tool effectively""" + + namespaced_name: Optional[str] = None + """Optional namespaced name for declarative filtering (e.g., "playwright/navigate" for MCP + tools) + """ + parameters: Optional[Dict[str, Any]] = None + """JSON Schema for the tool's input parameters""" + + @staticmethod + def from_dict(obj: Any) -> 'Tool': + assert isinstance(obj, dict) + description = from_str(obj.get("description")) + name = from_str(obj.get("name")) + instructions = from_union([from_str, from_none], obj.get("instructions")) + namespaced_name = from_union([from_str, from_none], obj.get("namespacedName")) + parameters = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("parameters")) + return Tool(description, name, instructions, namespaced_name, parameters) + + def to_dict(self) -> dict: + result: dict = {} + result["description"] = from_str(self.description) + result["name"] = from_str(self.name) + if self.instructions is not None: + result["instructions"] = from_union([from_str, from_none], self.instructions) + if self.namespaced_name is not None: + result["namespacedName"] = from_union([from_str, from_none], self.namespaced_name) + if self.parameters is not None: + result["parameters"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.parameters) + return result + + +@dataclass +class ToolsListResult: + tools: List[Tool] + """List of available built-in tools with metadata""" + + @staticmethod + def from_dict(obj: Any) -> 'ToolsListResult': + assert isinstance(obj, dict) + tools = from_list(Tool.from_dict, obj.get("tools")) + return ToolsListResult(tools) + + def to_dict(self) -> dict: + result: dict = {} + result["tools"] = from_list(lambda x: to_class(Tool, x), self.tools) + return result + + +@dataclass +class ToolsListParams: + model: Optional[str] = None + """Optional model ID — when provided, the returned tool list reflects model-specific + overrides + """ + + @staticmethod + def from_dict(obj: Any) -> 'ToolsListParams': + assert isinstance(obj, dict) + model = from_union([from_str, from_none], obj.get("model")) + return ToolsListParams(model) + + def to_dict(self) -> dict: + result: dict = {} + if self.model is not None: + result["model"] = from_union([from_str, from_none], self.model) + return result + + +@dataclass +class QuotaSnapshot: + entitlement_requests: float + """Number of requests included in the entitlement""" + + overage: float + """Number of overage requests made this period""" + + overage_allowed_with_exhausted_quota: bool + """Whether pay-per-request usage is allowed when quota is exhausted""" + + remaining_percentage: float + """Percentage of entitlement remaining""" + + used_requests: float + """Number of requests used so far this period""" + + reset_date: Optional[str] = None + """Date when the quota resets (ISO 8601)""" + + @staticmethod + def from_dict(obj: Any) -> 'QuotaSnapshot': + assert isinstance(obj, dict) + entitlement_requests = from_float(obj.get("entitlementRequests")) + overage = from_float(obj.get("overage")) + overage_allowed_with_exhausted_quota = from_bool(obj.get("overageAllowedWithExhaustedQuota")) + remaining_percentage = from_float(obj.get("remainingPercentage")) + used_requests = from_float(obj.get("usedRequests")) + reset_date = from_union([from_str, from_none], obj.get("resetDate")) + return QuotaSnapshot(entitlement_requests, overage, overage_allowed_with_exhausted_quota, remaining_percentage, used_requests, reset_date) + + def to_dict(self) -> dict: + result: dict = {} + result["entitlementRequests"] = to_float(self.entitlement_requests) + result["overage"] = to_float(self.overage) + result["overageAllowedWithExhaustedQuota"] = from_bool(self.overage_allowed_with_exhausted_quota) + result["remainingPercentage"] = to_float(self.remaining_percentage) + result["usedRequests"] = to_float(self.used_requests) + if self.reset_date is not None: + result["resetDate"] = from_union([from_str, from_none], self.reset_date) + return result + + +@dataclass +class AccountGetQuotaResult: + quota_snapshots: Dict[str, QuotaSnapshot] + """Quota snapshots keyed by type (e.g., chat, completions, premium_interactions)""" + + @staticmethod + def from_dict(obj: Any) -> 'AccountGetQuotaResult': + assert isinstance(obj, dict) + quota_snapshots = from_dict(QuotaSnapshot.from_dict, obj.get("quotaSnapshots")) + return AccountGetQuotaResult(quota_snapshots) + + def to_dict(self) -> dict: + result: dict = {} + result["quotaSnapshots"] = from_dict(lambda x: to_class(QuotaSnapshot, x), self.quota_snapshots) + return result + + +@dataclass +class SessionModelGetCurrentResult: + model_id: Optional[str] = None + + @staticmethod + def from_dict(obj: Any) -> 'SessionModelGetCurrentResult': + assert isinstance(obj, dict) + model_id = from_union([from_str, from_none], obj.get("modelId")) + return SessionModelGetCurrentResult(model_id) + + def to_dict(self) -> dict: + result: dict = {} + if self.model_id is not None: + result["modelId"] = from_union([from_str, from_none], self.model_id) + return result + + +@dataclass +class SessionModelSwitchToResult: + model_id: Optional[str] = None + + @staticmethod + def from_dict(obj: Any) -> 'SessionModelSwitchToResult': + assert isinstance(obj, dict) + model_id = from_union([from_str, from_none], obj.get("modelId")) + return SessionModelSwitchToResult(model_id) + + def to_dict(self) -> dict: + result: dict = {} + if self.model_id is not None: + result["modelId"] = from_union([from_str, from_none], self.model_id) + return result + + +@dataclass +class SessionModelSwitchToParams: + model_id: str + + @staticmethod + def from_dict(obj: Any) -> 'SessionModelSwitchToParams': + assert isinstance(obj, dict) + model_id = from_str(obj.get("modelId")) + return SessionModelSwitchToParams(model_id) + + def to_dict(self) -> dict: + result: dict = {} + result["modelId"] = from_str(self.model_id) + return result + + +class Mode(Enum): + """The current agent mode. + + The agent mode after switching. + + The mode to switch to. Valid values: "interactive", "plan", "autopilot". + """ + AUTOPILOT = "autopilot" + INTERACTIVE = "interactive" + PLAN = "plan" + + +@dataclass +class SessionModeGetResult: + mode: Mode + """The current agent mode.""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionModeGetResult': + assert isinstance(obj, dict) + mode = Mode(obj.get("mode")) + return SessionModeGetResult(mode) + + def to_dict(self) -> dict: + result: dict = {} + result["mode"] = to_enum(Mode, self.mode) + return result + + +@dataclass +class SessionModeSetResult: + mode: Mode + """The agent mode after switching.""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionModeSetResult': + assert isinstance(obj, dict) + mode = Mode(obj.get("mode")) + return SessionModeSetResult(mode) + + def to_dict(self) -> dict: + result: dict = {} + result["mode"] = to_enum(Mode, self.mode) + return result + + +@dataclass +class SessionModeSetParams: + mode: Mode + """The mode to switch to. Valid values: "interactive", "plan", "autopilot".""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionModeSetParams': + assert isinstance(obj, dict) + mode = Mode(obj.get("mode")) + return SessionModeSetParams(mode) + + def to_dict(self) -> dict: + result: dict = {} + result["mode"] = to_enum(Mode, self.mode) + return result + + +@dataclass +class SessionPlanReadResult: + exists: bool + """Whether plan.md exists in the workspace""" + + content: Optional[str] = None + """The content of plan.md, or null if it does not exist""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionPlanReadResult': + assert isinstance(obj, dict) + exists = from_bool(obj.get("exists")) + content = from_union([from_none, from_str], obj.get("content")) + return SessionPlanReadResult(exists, content) + + def to_dict(self) -> dict: + result: dict = {} + result["exists"] = from_bool(self.exists) + result["content"] = from_union([from_none, from_str], self.content) + return result + + +@dataclass +class SessionPlanUpdateResult: + @staticmethod + def from_dict(obj: Any) -> 'SessionPlanUpdateResult': + assert isinstance(obj, dict) + return SessionPlanUpdateResult() + + def to_dict(self) -> dict: + result: dict = {} + return result + + +@dataclass +class SessionPlanUpdateParams: + content: str + """The new content for plan.md""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionPlanUpdateParams': + assert isinstance(obj, dict) + content = from_str(obj.get("content")) + return SessionPlanUpdateParams(content) + + def to_dict(self) -> dict: + result: dict = {} + result["content"] = from_str(self.content) + return result + + +@dataclass +class SessionPlanDeleteResult: + @staticmethod + def from_dict(obj: Any) -> 'SessionPlanDeleteResult': + assert isinstance(obj, dict) + return SessionPlanDeleteResult() + + def to_dict(self) -> dict: + result: dict = {} + return result + + +@dataclass +class SessionWorkspaceListFilesResult: + files: List[str] + """Relative file paths in the workspace files directory""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionWorkspaceListFilesResult': + assert isinstance(obj, dict) + files = from_list(from_str, obj.get("files")) + return SessionWorkspaceListFilesResult(files) + + def to_dict(self) -> dict: + result: dict = {} + result["files"] = from_list(from_str, self.files) + return result + + +@dataclass +class SessionWorkspaceReadFileResult: + content: str + """File content as a UTF-8 string""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionWorkspaceReadFileResult': + assert isinstance(obj, dict) + content = from_str(obj.get("content")) + return SessionWorkspaceReadFileResult(content) + + def to_dict(self) -> dict: + result: dict = {} + result["content"] = from_str(self.content) + return result + + +@dataclass +class SessionWorkspaceReadFileParams: + path: str + """Relative path within the workspace files directory""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionWorkspaceReadFileParams': + assert isinstance(obj, dict) + path = from_str(obj.get("path")) + return SessionWorkspaceReadFileParams(path) + + def to_dict(self) -> dict: + result: dict = {} + result["path"] = from_str(self.path) + return result + + +@dataclass +class SessionWorkspaceCreateFileResult: + @staticmethod + def from_dict(obj: Any) -> 'SessionWorkspaceCreateFileResult': + assert isinstance(obj, dict) + return SessionWorkspaceCreateFileResult() + + def to_dict(self) -> dict: + result: dict = {} + return result + + +@dataclass +class SessionWorkspaceCreateFileParams: + content: str + """File content to write as a UTF-8 string""" + + path: str + """Relative path within the workspace files directory""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionWorkspaceCreateFileParams': + assert isinstance(obj, dict) + content = from_str(obj.get("content")) + path = from_str(obj.get("path")) + return SessionWorkspaceCreateFileParams(content, path) + + def to_dict(self) -> dict: + result: dict = {} + result["content"] = from_str(self.content) + result["path"] = from_str(self.path) + return result + + +@dataclass +class SessionFleetStartResult: + started: bool + """Whether fleet mode was successfully activated""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionFleetStartResult': + assert isinstance(obj, dict) + started = from_bool(obj.get("started")) + return SessionFleetStartResult(started) + + def to_dict(self) -> dict: + result: dict = {} + result["started"] = from_bool(self.started) + return result + + +@dataclass +class SessionFleetStartParams: + prompt: Optional[str] = None + """Optional user prompt to combine with fleet instructions""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionFleetStartParams': + assert isinstance(obj, dict) + prompt = from_union([from_str, from_none], obj.get("prompt")) + return SessionFleetStartParams(prompt) + + def to_dict(self) -> dict: + result: dict = {} + if self.prompt is not None: + result["prompt"] = from_union([from_str, from_none], self.prompt) + return result + + +def ping_result_from_dict(s: Any) -> PingResult: + return PingResult.from_dict(s) + + +def ping_result_to_dict(x: PingResult) -> Any: + return to_class(PingResult, x) + + +def ping_params_from_dict(s: Any) -> PingParams: + return PingParams.from_dict(s) + + +def ping_params_to_dict(x: PingParams) -> Any: + return to_class(PingParams, x) + + +def models_list_result_from_dict(s: Any) -> ModelsListResult: + return ModelsListResult.from_dict(s) + + +def models_list_result_to_dict(x: ModelsListResult) -> Any: + return to_class(ModelsListResult, x) + + +def tools_list_result_from_dict(s: Any) -> ToolsListResult: + return ToolsListResult.from_dict(s) + + +def tools_list_result_to_dict(x: ToolsListResult) -> Any: + return to_class(ToolsListResult, x) + + +def tools_list_params_from_dict(s: Any) -> ToolsListParams: + return ToolsListParams.from_dict(s) + + +def tools_list_params_to_dict(x: ToolsListParams) -> Any: + return to_class(ToolsListParams, x) + + +def account_get_quota_result_from_dict(s: Any) -> AccountGetQuotaResult: + return AccountGetQuotaResult.from_dict(s) + + +def account_get_quota_result_to_dict(x: AccountGetQuotaResult) -> Any: + return to_class(AccountGetQuotaResult, x) + + +def session_model_get_current_result_from_dict(s: Any) -> SessionModelGetCurrentResult: + return SessionModelGetCurrentResult.from_dict(s) + + +def session_model_get_current_result_to_dict(x: SessionModelGetCurrentResult) -> Any: + return to_class(SessionModelGetCurrentResult, x) + + +def session_model_switch_to_result_from_dict(s: Any) -> SessionModelSwitchToResult: + return SessionModelSwitchToResult.from_dict(s) + + +def session_model_switch_to_result_to_dict(x: SessionModelSwitchToResult) -> Any: + return to_class(SessionModelSwitchToResult, x) + + +def session_model_switch_to_params_from_dict(s: Any) -> SessionModelSwitchToParams: + return SessionModelSwitchToParams.from_dict(s) + + +def session_model_switch_to_params_to_dict(x: SessionModelSwitchToParams) -> Any: + return to_class(SessionModelSwitchToParams, x) + + +def session_mode_get_result_from_dict(s: Any) -> SessionModeGetResult: + return SessionModeGetResult.from_dict(s) + + +def session_mode_get_result_to_dict(x: SessionModeGetResult) -> Any: + return to_class(SessionModeGetResult, x) + + +def session_mode_set_result_from_dict(s: Any) -> SessionModeSetResult: + return SessionModeSetResult.from_dict(s) + + +def session_mode_set_result_to_dict(x: SessionModeSetResult) -> Any: + return to_class(SessionModeSetResult, x) + + +def session_mode_set_params_from_dict(s: Any) -> SessionModeSetParams: + return SessionModeSetParams.from_dict(s) + + +def session_mode_set_params_to_dict(x: SessionModeSetParams) -> Any: + return to_class(SessionModeSetParams, x) + + +def session_plan_read_result_from_dict(s: Any) -> SessionPlanReadResult: + return SessionPlanReadResult.from_dict(s) + + +def session_plan_read_result_to_dict(x: SessionPlanReadResult) -> Any: + return to_class(SessionPlanReadResult, x) + + +def session_plan_update_result_from_dict(s: Any) -> SessionPlanUpdateResult: + return SessionPlanUpdateResult.from_dict(s) + + +def session_plan_update_result_to_dict(x: SessionPlanUpdateResult) -> Any: + return to_class(SessionPlanUpdateResult, x) + + +def session_plan_update_params_from_dict(s: Any) -> SessionPlanUpdateParams: + return SessionPlanUpdateParams.from_dict(s) + + +def session_plan_update_params_to_dict(x: SessionPlanUpdateParams) -> Any: + return to_class(SessionPlanUpdateParams, x) + + +def session_plan_delete_result_from_dict(s: Any) -> SessionPlanDeleteResult: + return SessionPlanDeleteResult.from_dict(s) + + +def session_plan_delete_result_to_dict(x: SessionPlanDeleteResult) -> Any: + return to_class(SessionPlanDeleteResult, x) + + +def session_workspace_list_files_result_from_dict(s: Any) -> SessionWorkspaceListFilesResult: + return SessionWorkspaceListFilesResult.from_dict(s) + + +def session_workspace_list_files_result_to_dict(x: SessionWorkspaceListFilesResult) -> Any: + return to_class(SessionWorkspaceListFilesResult, x) + + +def session_workspace_read_file_result_from_dict(s: Any) -> SessionWorkspaceReadFileResult: + return SessionWorkspaceReadFileResult.from_dict(s) + + +def session_workspace_read_file_result_to_dict(x: SessionWorkspaceReadFileResult) -> Any: + return to_class(SessionWorkspaceReadFileResult, x) + + +def session_workspace_read_file_params_from_dict(s: Any) -> SessionWorkspaceReadFileParams: + return SessionWorkspaceReadFileParams.from_dict(s) + + +def session_workspace_read_file_params_to_dict(x: SessionWorkspaceReadFileParams) -> Any: + return to_class(SessionWorkspaceReadFileParams, x) + + +def session_workspace_create_file_result_from_dict(s: Any) -> SessionWorkspaceCreateFileResult: + return SessionWorkspaceCreateFileResult.from_dict(s) + + +def session_workspace_create_file_result_to_dict(x: SessionWorkspaceCreateFileResult) -> Any: + return to_class(SessionWorkspaceCreateFileResult, x) + + +def session_workspace_create_file_params_from_dict(s: Any) -> SessionWorkspaceCreateFileParams: + return SessionWorkspaceCreateFileParams.from_dict(s) + + +def session_workspace_create_file_params_to_dict(x: SessionWorkspaceCreateFileParams) -> Any: + return to_class(SessionWorkspaceCreateFileParams, x) + + +def session_fleet_start_result_from_dict(s: Any) -> SessionFleetStartResult: + return SessionFleetStartResult.from_dict(s) + + +def session_fleet_start_result_to_dict(x: SessionFleetStartResult) -> Any: + return to_class(SessionFleetStartResult, x) + + +def session_fleet_start_params_from_dict(s: Any) -> SessionFleetStartParams: + return SessionFleetStartParams.from_dict(s) + + +def session_fleet_start_params_to_dict(x: SessionFleetStartParams) -> Any: + return to_class(SessionFleetStartParams, x) + + +class ModelsApi: + def __init__(self, client: "JsonRpcClient"): + self._client = client + + async def list(self) -> ModelsListResult: + return ModelsListResult.from_dict(await self._client.request("models.list", {})) + + +class ToolsApi: + def __init__(self, client: "JsonRpcClient"): + self._client = client + + async def list(self, params: ToolsListParams) -> ToolsListResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + return ToolsListResult.from_dict(await self._client.request("tools.list", params_dict)) + + +class AccountApi: + def __init__(self, client: "JsonRpcClient"): + self._client = client + + async def get_quota(self) -> AccountGetQuotaResult: + return AccountGetQuotaResult.from_dict(await self._client.request("account.getQuota", {})) + + +class ServerRpc: + """Typed server-scoped RPC methods.""" + def __init__(self, client: "JsonRpcClient"): + self._client = client + self.models = ModelsApi(client) + self.tools = ToolsApi(client) + self.account = AccountApi(client) + + async def ping(self, params: PingParams) -> PingResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + return PingResult.from_dict(await self._client.request("ping", params_dict)) + + +class ModelApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def get_current(self) -> SessionModelGetCurrentResult: + return SessionModelGetCurrentResult.from_dict(await self._client.request("session.model.getCurrent", {"sessionId": self._session_id})) + + async def switch_to(self, params: SessionModelSwitchToParams) -> SessionModelSwitchToResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionModelSwitchToResult.from_dict(await self._client.request("session.model.switchTo", params_dict)) + + +class ModeApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def get(self) -> SessionModeGetResult: + return SessionModeGetResult.from_dict(await self._client.request("session.mode.get", {"sessionId": self._session_id})) + + async def set(self, params: SessionModeSetParams) -> SessionModeSetResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionModeSetResult.from_dict(await self._client.request("session.mode.set", params_dict)) + + +class PlanApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def read(self) -> SessionPlanReadResult: + return SessionPlanReadResult.from_dict(await self._client.request("session.plan.read", {"sessionId": self._session_id})) + + async def update(self, params: SessionPlanUpdateParams) -> SessionPlanUpdateResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionPlanUpdateResult.from_dict(await self._client.request("session.plan.update", params_dict)) + + async def delete(self) -> SessionPlanDeleteResult: + return SessionPlanDeleteResult.from_dict(await self._client.request("session.plan.delete", {"sessionId": self._session_id})) + + +class WorkspaceApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def list_files(self) -> SessionWorkspaceListFilesResult: + return SessionWorkspaceListFilesResult.from_dict(await self._client.request("session.workspace.listFiles", {"sessionId": self._session_id})) + + async def read_file(self, params: SessionWorkspaceReadFileParams) -> SessionWorkspaceReadFileResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionWorkspaceReadFileResult.from_dict(await self._client.request("session.workspace.readFile", params_dict)) + + async def create_file(self, params: SessionWorkspaceCreateFileParams) -> SessionWorkspaceCreateFileResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionWorkspaceCreateFileResult.from_dict(await self._client.request("session.workspace.createFile", params_dict)) + + +class FleetApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def start(self, params: SessionFleetStartParams) -> SessionFleetStartResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionFleetStartResult.from_dict(await self._client.request("session.fleet.start", params_dict)) + + +class SessionRpc: + """Typed session-scoped RPC methods.""" + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + self.model = ModelApi(client, session_id) + self.mode = ModeApi(client, session_id) + self.plan = PlanApi(client, session_id) + self.workspace = WorkspaceApi(client, session_id) + self.fleet = FleetApi(client, session_id) + diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 84dff82e1..0d588058a 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -1,18 +1,11 @@ """ AUTO-GENERATED FILE - DO NOT EDIT - -Generated from: @github/copilot/session-events.schema.json -Generated by: scripts/generate-session-types.ts -Generated at: 2026-02-06T20:38:23.376Z - -To update these types: -1. Update the schema in copilot-agent-runtime -2. Run: npm run generate:session-types +Generated from: session-events.schema.json """ +from enum import Enum from dataclasses import dataclass from typing import Any, Optional, List, Dict, Union, TypeVar, Type, cast, Callable -from enum import Enum from datetime import datetime from uuid import UUID import dateutil.parser @@ -51,7 +44,7 @@ def from_union(fs, x): for f in fs: try: return f(x) - except: + except Exception: pass assert False @@ -85,6 +78,32 @@ def from_int(x: Any) -> int: return x +class AgentMode(Enum): + AUTOPILOT = "autopilot" + INTERACTIVE = "interactive" + PLAN = "plan" + SHELL = "shell" + + +@dataclass +class LineRange: + end: float + start: float + + @staticmethod + def from_dict(obj: Any) -> 'LineRange': + assert isinstance(obj, dict) + end = from_float(obj.get("end")) + start = from_float(obj.get("start")) + return LineRange(end, start) + + def to_dict(self) -> dict: + result: dict = {} + result["end"] = to_float(self.end) + result["start"] = to_float(self.start) + return result + + @dataclass class End: character: float @@ -152,6 +171,7 @@ class AttachmentType(Enum): class Attachment: display_name: str type: AttachmentType + line_range: Optional[LineRange] = None path: Optional[str] = None file_path: Optional[str] = None selection: Optional[Selection] = None @@ -162,16 +182,19 @@ def from_dict(obj: Any) -> 'Attachment': assert isinstance(obj, dict) display_name = from_str(obj.get("displayName")) type = AttachmentType(obj.get("type")) + line_range = from_union([LineRange.from_dict, from_none], obj.get("lineRange")) path = from_union([from_str, from_none], obj.get("path")) file_path = from_union([from_str, from_none], obj.get("filePath")) selection = from_union([Selection.from_dict, from_none], obj.get("selection")) text = from_union([from_str, from_none], obj.get("text")) - return Attachment(display_name, type, path, file_path, selection, text) + return Attachment(display_name, type, line_range, path, file_path, selection, text) def to_dict(self) -> dict: result: dict = {} result["displayName"] = from_str(self.display_name) result["type"] = to_enum(AttachmentType, self.type) + if self.line_range is not None: + result["lineRange"] = from_union([lambda x: to_class(LineRange, x), from_none], self.line_range) if self.path is not None: result["path"] = from_union([from_str, from_none], self.path) if self.file_path is not None: @@ -363,6 +386,12 @@ def to_dict(self) -> dict: return result +class Operation(Enum): + CREATE = "create" + DELETE = "delete" + UPDATE = "update" + + @dataclass class QuotaSnapshot: entitlement_requests: float @@ -402,18 +431,18 @@ def to_dict(self) -> dict: @dataclass -class Repository: +class RepositoryClass: name: str owner: str branch: Optional[str] = None @staticmethod - def from_dict(obj: Any) -> 'Repository': + def from_dict(obj: Any) -> 'RepositoryClass': assert isinstance(obj, dict) name = from_str(obj.get("name")) owner = from_str(obj.get("owner")) branch = from_union([from_str, from_none], obj.get("branch")) - return Repository(name, owner, branch) + return RepositoryClass(name, owner, branch) def to_dict(self) -> dict: result: dict = {} @@ -424,21 +453,159 @@ def to_dict(self) -> dict: return result +class Theme(Enum): + DARK = "dark" + LIGHT = "light" + + +@dataclass +class Icon: + src: str + mime_type: Optional[str] = None + sizes: Optional[List[str]] = None + theme: Optional[Theme] = None + + @staticmethod + def from_dict(obj: Any) -> 'Icon': + assert isinstance(obj, dict) + src = from_str(obj.get("src")) + mime_type = from_union([from_str, from_none], obj.get("mimeType")) + sizes = from_union([lambda x: from_list(from_str, x), from_none], obj.get("sizes")) + theme = from_union([Theme, from_none], obj.get("theme")) + return Icon(src, mime_type, sizes, theme) + + def to_dict(self) -> dict: + result: dict = {} + result["src"] = from_str(self.src) + if self.mime_type is not None: + result["mimeType"] = from_union([from_str, from_none], self.mime_type) + if self.sizes is not None: + result["sizes"] = from_union([lambda x: from_list(from_str, x), from_none], self.sizes) + if self.theme is not None: + result["theme"] = from_union([lambda x: to_enum(Theme, x), from_none], self.theme) + return result + + +@dataclass +class Resource: + uri: str + mime_type: Optional[str] = None + text: Optional[str] = None + blob: Optional[str] = None + + @staticmethod + def from_dict(obj: Any) -> 'Resource': + assert isinstance(obj, dict) + uri = from_str(obj.get("uri")) + mime_type = from_union([from_str, from_none], obj.get("mimeType")) + text = from_union([from_str, from_none], obj.get("text")) + blob = from_union([from_str, from_none], obj.get("blob")) + return Resource(uri, mime_type, text, blob) + + def to_dict(self) -> dict: + result: dict = {} + result["uri"] = from_str(self.uri) + if self.mime_type is not None: + result["mimeType"] = from_union([from_str, from_none], self.mime_type) + if self.text is not None: + result["text"] = from_union([from_str, from_none], self.text) + if self.blob is not None: + result["blob"] = from_union([from_str, from_none], self.blob) + return result + + +class ContentType(Enum): + AUDIO = "audio" + IMAGE = "image" + RESOURCE = "resource" + RESOURCE_LINK = "resource_link" + TERMINAL = "terminal" + TEXT = "text" + + +@dataclass +class Content: + type: ContentType + text: Optional[str] = None + cwd: Optional[str] = None + exit_code: Optional[float] = None + data: Optional[str] = None + mime_type: Optional[str] = None + description: Optional[str] = None + icons: Optional[List[Icon]] = None + name: Optional[str] = None + size: Optional[float] = None + title: Optional[str] = None + uri: Optional[str] = None + resource: Optional[Resource] = None + + @staticmethod + def from_dict(obj: Any) -> 'Content': + assert isinstance(obj, dict) + type = ContentType(obj.get("type")) + text = from_union([from_str, from_none], obj.get("text")) + cwd = from_union([from_str, from_none], obj.get("cwd")) + exit_code = from_union([from_float, from_none], obj.get("exitCode")) + data = from_union([from_str, from_none], obj.get("data")) + mime_type = from_union([from_str, from_none], obj.get("mimeType")) + description = from_union([from_str, from_none], obj.get("description")) + icons = from_union([lambda x: from_list(Icon.from_dict, x), from_none], obj.get("icons")) + name = from_union([from_str, from_none], obj.get("name")) + size = from_union([from_float, from_none], obj.get("size")) + title = from_union([from_str, from_none], obj.get("title")) + uri = from_union([from_str, from_none], obj.get("uri")) + resource = from_union([Resource.from_dict, from_none], obj.get("resource")) + return Content(type, text, cwd, exit_code, data, mime_type, description, icons, name, size, title, uri, resource) + + def to_dict(self) -> dict: + result: dict = {} + result["type"] = to_enum(ContentType, self.type) + if self.text is not None: + result["text"] = from_union([from_str, from_none], self.text) + if self.cwd is not None: + result["cwd"] = from_union([from_str, from_none], self.cwd) + if self.exit_code is not None: + result["exitCode"] = from_union([to_float, from_none], self.exit_code) + if self.data is not None: + result["data"] = from_union([from_str, from_none], self.data) + if self.mime_type is not None: + result["mimeType"] = from_union([from_str, from_none], self.mime_type) + if self.description is not None: + result["description"] = from_union([from_str, from_none], self.description) + if self.icons is not None: + result["icons"] = from_union([lambda x: from_list(lambda x: to_class(Icon, x), x), from_none], self.icons) + if self.name is not None: + result["name"] = from_union([from_str, from_none], self.name) + if self.size is not None: + result["size"] = from_union([to_float, from_none], self.size) + if self.title is not None: + result["title"] = from_union([from_str, from_none], self.title) + if self.uri is not None: + result["uri"] = from_union([from_str, from_none], self.uri) + if self.resource is not None: + result["resource"] = from_union([lambda x: to_class(Resource, x), from_none], self.resource) + return result + + @dataclass class Result: content: str + contents: Optional[List[Content]] = None detailed_content: Optional[str] = None @staticmethod def from_dict(obj: Any) -> 'Result': assert isinstance(obj, dict) content = from_str(obj.get("content")) + contents = from_union([lambda x: from_list(Content.from_dict, x), from_none], obj.get("contents")) detailed_content = from_union([from_str, from_none], obj.get("detailedContent")) - return Result(content, detailed_content) + return Result(content, contents, detailed_content) def to_dict(self) -> dict: result: dict = {} result["content"] = from_str(self.content) + if self.contents is not None: + result["contents"] = from_union([lambda x: from_list(lambda x: to_class(Content, x), x), from_none], self.contents) if self.detailed_content is not None: result["detailedContent"] = from_union([from_str, from_none], self.detailed_content) return result @@ -507,12 +674,20 @@ class Data: provider_call_id: Optional[str] = None stack: Optional[str] = None status_code: Optional[int] = None + title: Optional[str] = None info_type: Optional[str] = None + warning_type: Optional[str] = None new_model: Optional[str] = None previous_model: Optional[str] = None + new_mode: Optional[str] = None + previous_mode: Optional[str] = None + operation: Optional[Operation] = None + path: Optional[str] = None + """Relative path within the workspace files directory""" + handoff_time: Optional[datetime] = None remote_session_id: Optional[str] = None - repository: Optional[Repository] = None + repository: Optional[Union[RepositoryClass, str]] = None source_type: Optional[SourceType] = None summary: Optional[str] = None messages_removed_during_truncation: Optional[float] = None @@ -533,6 +708,9 @@ class Data: shutdown_type: Optional[ShutdownType] = None total_api_duration_ms: Optional[float] = None total_premium_requests: Optional[float] = None + branch: Optional[str] = None + cwd: Optional[str] = None + git_root: Optional[str] = None current_tokens: Optional[float] = None messages_length: Optional[float] = None checkpoint_number: Optional[float] = None @@ -547,6 +725,7 @@ class Data: success: Optional[bool] = None summary_content: Optional[str] = None tokens_removed: Optional[float] = None + agent_mode: Optional[AgentMode] = None attachments: Optional[List[Attachment]] = None content: Optional[str] = None source: Optional[str] = None @@ -558,6 +737,7 @@ class Data: encrypted_content: Optional[str] = None message_id: Optional[str] = None parent_tool_call_id: Optional[str] = None + phase: Optional[str] = None reasoning_opaque: Optional[str] = None reasoning_text: Optional[str] = None tool_requests: Optional[List[ToolRequest]] = None @@ -585,7 +765,6 @@ class Data: tool_telemetry: Optional[Dict[str, Any]] = None allowed_tools: Optional[List[str]] = None name: Optional[str] = None - path: Optional[str] = None agent_description: Optional[str] = None agent_display_name: Optional[str] = None agent_name: Optional[str] = None @@ -614,12 +793,18 @@ def from_dict(obj: Any) -> 'Data': provider_call_id = from_union([from_str, from_none], obj.get("providerCallId")) stack = from_union([from_str, from_none], obj.get("stack")) status_code = from_union([from_int, from_none], obj.get("statusCode")) + title = from_union([from_str, from_none], obj.get("title")) info_type = from_union([from_str, from_none], obj.get("infoType")) + warning_type = from_union([from_str, from_none], obj.get("warningType")) new_model = from_union([from_str, from_none], obj.get("newModel")) previous_model = from_union([from_str, from_none], obj.get("previousModel")) + new_mode = from_union([from_str, from_none], obj.get("newMode")) + previous_mode = from_union([from_str, from_none], obj.get("previousMode")) + operation = from_union([Operation, from_none], obj.get("operation")) + path = from_union([from_str, from_none], obj.get("path")) handoff_time = from_union([from_datetime, from_none], obj.get("handoffTime")) remote_session_id = from_union([from_str, from_none], obj.get("remoteSessionId")) - repository = from_union([Repository.from_dict, from_none], obj.get("repository")) + repository = from_union([RepositoryClass.from_dict, from_str, from_none], obj.get("repository")) source_type = from_union([SourceType, from_none], obj.get("sourceType")) summary = from_union([from_str, from_none], obj.get("summary")) messages_removed_during_truncation = from_union([from_float, from_none], obj.get("messagesRemovedDuringTruncation")) @@ -640,6 +825,9 @@ def from_dict(obj: Any) -> 'Data': shutdown_type = from_union([ShutdownType, from_none], obj.get("shutdownType")) total_api_duration_ms = from_union([from_float, from_none], obj.get("totalApiDurationMs")) total_premium_requests = from_union([from_float, from_none], obj.get("totalPremiumRequests")) + branch = from_union([from_str, from_none], obj.get("branch")) + cwd = from_union([from_str, from_none], obj.get("cwd")) + git_root = from_union([from_str, from_none], obj.get("gitRoot")) current_tokens = from_union([from_float, from_none], obj.get("currentTokens")) messages_length = from_union([from_float, from_none], obj.get("messagesLength")) checkpoint_number = from_union([from_float, from_none], obj.get("checkpointNumber")) @@ -654,6 +842,7 @@ def from_dict(obj: Any) -> 'Data': success = from_union([from_bool, from_none], obj.get("success")) summary_content = from_union([from_str, from_none], obj.get("summaryContent")) tokens_removed = from_union([from_float, from_none], obj.get("tokensRemoved")) + agent_mode = from_union([AgentMode, from_none], obj.get("agentMode")) attachments = from_union([lambda x: from_list(Attachment.from_dict, x), from_none], obj.get("attachments")) content = from_union([from_str, from_none], obj.get("content")) source = from_union([from_str, from_none], obj.get("source")) @@ -665,6 +854,7 @@ def from_dict(obj: Any) -> 'Data': encrypted_content = from_union([from_str, from_none], obj.get("encryptedContent")) message_id = from_union([from_str, from_none], obj.get("messageId")) parent_tool_call_id = from_union([from_str, from_none], obj.get("parentToolCallId")) + phase = from_union([from_str, from_none], obj.get("phase")) reasoning_opaque = from_union([from_str, from_none], obj.get("reasoningOpaque")) reasoning_text = from_union([from_str, from_none], obj.get("reasoningText")) tool_requests = from_union([lambda x: from_list(ToolRequest.from_dict, x), from_none], obj.get("toolRequests")) @@ -692,7 +882,6 @@ def from_dict(obj: Any) -> 'Data': tool_telemetry = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("toolTelemetry")) allowed_tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("allowedTools")) name = from_union([from_str, from_none], obj.get("name")) - path = from_union([from_str, from_none], obj.get("path")) agent_description = from_union([from_str, from_none], obj.get("agentDescription")) agent_display_name = from_union([from_str, from_none], obj.get("agentDisplayName")) agent_name = from_union([from_str, from_none], obj.get("agentName")) @@ -703,7 +892,7 @@ def from_dict(obj: Any) -> 'Data': output = obj.get("output") metadata = from_union([Metadata.from_dict, from_none], obj.get("metadata")) role = from_union([Role, from_none], obj.get("role")) - return Data(context, copilot_version, producer, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, info_type, new_model, previous_model, handoff_time, remote_session_id, repository, source_type, summary, messages_removed_during_truncation, performed_by, post_truncation_messages_length, post_truncation_tokens_in_messages, pre_truncation_messages_length, pre_truncation_tokens_in_messages, token_limit, tokens_removed_during_truncation, events_removed, up_to_event_id, code_changes, current_model, error_reason, model_metrics, session_start_time, shutdown_type, total_api_duration_ms, total_premium_requests, current_tokens, messages_length, checkpoint_number, checkpoint_path, compaction_tokens_used, error, messages_removed, post_compaction_tokens, pre_compaction_messages_length, pre_compaction_tokens, request_id, success, summary_content, tokens_removed, attachments, content, source, transformed_content, turn_id, intent, reasoning_id, delta_content, encrypted_content, message_id, parent_tool_call_id, reasoning_opaque, reasoning_text, tool_requests, total_response_size_bytes, api_call_id, cache_read_tokens, cache_write_tokens, cost, duration, initiator, input_tokens, model, output_tokens, quota_snapshots, reason, arguments, tool_call_id, tool_name, mcp_server_name, mcp_tool_name, partial_output, progress_message, is_user_requested, result, tool_telemetry, allowed_tools, name, path, agent_description, agent_display_name, agent_name, tools, hook_invocation_id, hook_type, input, output, metadata, role) + return Data(context, copilot_version, producer, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, title, info_type, warning_type, new_model, previous_model, new_mode, previous_mode, operation, path, handoff_time, remote_session_id, repository, source_type, summary, messages_removed_during_truncation, performed_by, post_truncation_messages_length, post_truncation_tokens_in_messages, pre_truncation_messages_length, pre_truncation_tokens_in_messages, token_limit, tokens_removed_during_truncation, events_removed, up_to_event_id, code_changes, current_model, error_reason, model_metrics, session_start_time, shutdown_type, total_api_duration_ms, total_premium_requests, branch, cwd, git_root, current_tokens, messages_length, checkpoint_number, checkpoint_path, compaction_tokens_used, error, messages_removed, post_compaction_tokens, pre_compaction_messages_length, pre_compaction_tokens, request_id, success, summary_content, tokens_removed, agent_mode, attachments, content, source, transformed_content, turn_id, intent, reasoning_id, delta_content, encrypted_content, message_id, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, total_response_size_bytes, api_call_id, cache_read_tokens, cache_write_tokens, cost, duration, initiator, input_tokens, model, output_tokens, quota_snapshots, reason, arguments, tool_call_id, tool_name, mcp_server_name, mcp_tool_name, partial_output, progress_message, is_user_requested, result, tool_telemetry, allowed_tools, name, agent_description, agent_display_name, agent_name, tools, hook_invocation_id, hook_type, input, output, metadata, role) def to_dict(self) -> dict: result: dict = {} @@ -735,18 +924,30 @@ def to_dict(self) -> dict: result["stack"] = from_union([from_str, from_none], self.stack) if self.status_code is not None: result["statusCode"] = from_union([from_int, from_none], self.status_code) + if self.title is not None: + result["title"] = from_union([from_str, from_none], self.title) if self.info_type is not None: result["infoType"] = from_union([from_str, from_none], self.info_type) + if self.warning_type is not None: + result["warningType"] = from_union([from_str, from_none], self.warning_type) if self.new_model is not None: result["newModel"] = from_union([from_str, from_none], self.new_model) if self.previous_model is not None: result["previousModel"] = from_union([from_str, from_none], self.previous_model) + if self.new_mode is not None: + result["newMode"] = from_union([from_str, from_none], self.new_mode) + if self.previous_mode is not None: + result["previousMode"] = from_union([from_str, from_none], self.previous_mode) + if self.operation is not None: + result["operation"] = from_union([lambda x: to_enum(Operation, x), from_none], self.operation) + if self.path is not None: + result["path"] = from_union([from_str, from_none], self.path) if self.handoff_time is not None: result["handoffTime"] = from_union([lambda x: x.isoformat(), from_none], self.handoff_time) if self.remote_session_id is not None: result["remoteSessionId"] = from_union([from_str, from_none], self.remote_session_id) if self.repository is not None: - result["repository"] = from_union([lambda x: to_class(Repository, x), from_none], self.repository) + result["repository"] = from_union([lambda x: to_class(RepositoryClass, x), from_str, from_none], self.repository) if self.source_type is not None: result["sourceType"] = from_union([lambda x: to_enum(SourceType, x), from_none], self.source_type) if self.summary is not None: @@ -787,6 +988,12 @@ def to_dict(self) -> dict: result["totalApiDurationMs"] = from_union([to_float, from_none], self.total_api_duration_ms) if self.total_premium_requests is not None: result["totalPremiumRequests"] = from_union([to_float, from_none], self.total_premium_requests) + if self.branch is not None: + result["branch"] = from_union([from_str, from_none], self.branch) + if self.cwd is not None: + result["cwd"] = from_union([from_str, from_none], self.cwd) + if self.git_root is not None: + result["gitRoot"] = from_union([from_str, from_none], self.git_root) if self.current_tokens is not None: result["currentTokens"] = from_union([to_float, from_none], self.current_tokens) if self.messages_length is not None: @@ -815,6 +1022,8 @@ def to_dict(self) -> dict: result["summaryContent"] = from_union([from_str, from_none], self.summary_content) if self.tokens_removed is not None: result["tokensRemoved"] = from_union([to_float, from_none], self.tokens_removed) + if self.agent_mode is not None: + result["agentMode"] = from_union([lambda x: to_enum(AgentMode, x), from_none], self.agent_mode) if self.attachments is not None: result["attachments"] = from_union([lambda x: from_list(lambda x: to_class(Attachment, x), x), from_none], self.attachments) if self.content is not None: @@ -837,6 +1046,8 @@ def to_dict(self) -> dict: result["messageId"] = from_union([from_str, from_none], self.message_id) if self.parent_tool_call_id is not None: result["parentToolCallId"] = from_union([from_str, from_none], self.parent_tool_call_id) + if self.phase is not None: + result["phase"] = from_union([from_str, from_none], self.phase) if self.reasoning_opaque is not None: result["reasoningOpaque"] = from_union([from_str, from_none], self.reasoning_opaque) if self.reasoning_text is not None: @@ -891,8 +1102,6 @@ def to_dict(self) -> dict: result["allowedTools"] = from_union([lambda x: from_list(from_str, x), from_none], self.allowed_tools) if self.name is not None: result["name"] = from_union([from_str, from_none], self.name) - if self.path is not None: - result["path"] = from_union([from_str, from_none], self.path) if self.agent_description is not None: result["agentDescription"] = from_union([from_str, from_none], self.agent_description) if self.agent_display_name is not None: @@ -931,17 +1140,23 @@ class SessionEventType(Enum): PENDING_MESSAGES_MODIFIED = "pending_messages.modified" SESSION_COMPACTION_COMPLETE = "session.compaction_complete" SESSION_COMPACTION_START = "session.compaction_start" + SESSION_CONTEXT_CHANGED = "session.context_changed" SESSION_ERROR = "session.error" SESSION_HANDOFF = "session.handoff" SESSION_IDLE = "session.idle" SESSION_INFO = "session.info" SESSION_MODEL_CHANGE = "session.model_change" + SESSION_MODE_CHANGED = "session.mode_changed" + SESSION_PLAN_CHANGED = "session.plan_changed" SESSION_RESUME = "session.resume" SESSION_SHUTDOWN = "session.shutdown" SESSION_SNAPSHOT_REWIND = "session.snapshot_rewind" SESSION_START = "session.start" + SESSION_TITLE_CHANGED = "session.title_changed" SESSION_TRUNCATION = "session.truncation" SESSION_USAGE_INFO = "session.usage_info" + SESSION_WARNING = "session.warning" + SESSION_WORKSPACE_FILE_CHANGED = "session.workspace_file_changed" SKILL_INVOKED = "skill.invoked" SUBAGENT_COMPLETED = "subagent.completed" SUBAGENT_FAILED = "subagent.failed" @@ -954,8 +1169,7 @@ class SessionEventType(Enum): TOOL_EXECUTION_START = "tool.execution_start" TOOL_USER_REQUESTED = "tool.user_requested" USER_MESSAGE = "user.message" - # UNKNOWN is used for forward compatibility - new event types from the server - # will map to this value instead of raising an error + # UNKNOWN is used for forward compatibility UNKNOWN = "unknown" @classmethod diff --git a/python/copilot/jsonrpc.py b/python/copilot/jsonrpc.py index b9322fd41..cb6c5408d 100644 --- a/python/copilot/jsonrpc.py +++ b/python/copilot/jsonrpc.py @@ -24,6 +24,12 @@ def __init__(self, code: int, message: str, data: Any = None): super().__init__(f"JSON-RPC Error {code}: {message}") +class ProcessExitedError(Exception): + """Error raised when the CLI process exits unexpectedly""" + + pass + + RequestHandler = Callable[[dict], Union[dict, Awaitable[dict]]] @@ -47,9 +53,13 @@ def __init__(self, process): self.request_handlers: dict[str, RequestHandler] = {} self._running = False self._read_thread: Optional[threading.Thread] = None + self._stderr_thread: Optional[threading.Thread] = None self._loop: Optional[asyncio.AbstractEventLoop] = None self._write_lock = threading.Lock() self._pending_lock = threading.Lock() + self._process_exit_error: Optional[str] = None + self._stderr_output: list[str] = [] + self._stderr_lock = threading.Lock() def start(self, loop: Optional[asyncio.AbstractEventLoop] = None): """Start listening for messages in background thread""" @@ -59,12 +69,39 @@ def start(self, loop: Optional[asyncio.AbstractEventLoop] = None): self._loop = loop or asyncio.get_running_loop() self._read_thread = threading.Thread(target=self._read_loop, daemon=True) self._read_thread.start() + # Start stderr reader thread if process has stderr + if hasattr(self.process, "stderr") and self.process.stderr: + self._stderr_thread = threading.Thread(target=self._stderr_loop, daemon=True) + self._stderr_thread.start() + + def _stderr_loop(self): + """Read stderr in background to capture error messages""" + try: + while self._running: + if not self.process.stderr: + break + line = self.process.stderr.readline() + if not line: + break + with self._stderr_lock: + self._stderr_output.append( + line.decode("utf-8") if isinstance(line, bytes) else line + ) + except Exception: + pass # Ignore errors reading stderr + + def get_stderr_output(self) -> str: + """Get captured stderr output""" + with self._stderr_lock: + return "".join(self._stderr_output).strip() async def stop(self): """Stop listening and clean up""" self._running = False if self._read_thread: self._read_thread.join(timeout=1.0) + if self._stderr_thread: + self._stderr_thread.join(timeout=1.0) async def request( self, method: str, params: Optional[dict] = None, timeout: float = 30.0 @@ -157,9 +194,43 @@ def _read_loop(self): message = self._read_message() if message: self._handle_message(message) + else: + # No message means stream closed - process likely exited + break + except EOFError: + # Stream closed - check if process exited + pass except Exception as e: if self._running: - print(f"JSON-RPC read loop error: {e}") + # Store error for pending requests + self._process_exit_error = str(e) + + # Process exited or read failed - fail all pending requests + if self._running: + self._fail_pending_requests() + + def _fail_pending_requests(self): + """Fail all pending requests when process exits""" + # Build error message with stderr output + stderr_output = self.get_stderr_output() + return_code = None + if hasattr(self.process, "poll"): + return_code = self.process.poll() + + if stderr_output: + error_msg = f"CLI process exited with code {return_code}\nstderr: {stderr_output}" + elif return_code is not None: + error_msg = f"CLI process exited with code {return_code}" + else: + error_msg = "CLI process exited unexpectedly" + + # Fail all pending requests + with self._pending_lock: + for request_id, future in list(self.pending_requests.items()): + if not future.done(): + exc = ProcessExitedError(error_msg) + loop = future.get_loop() + loop.call_soon_threadsafe(future.set_exception, exc) def _read_exact(self, num_bytes: int) -> bytes: """ diff --git a/python/copilot/session.py b/python/copilot/session.py index fb39e9fc3..7332f6c5f 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -10,16 +10,17 @@ import threading from typing import Any, Callable, Optional +from .generated.rpc import SessionRpc from .generated.session_events import SessionEvent, SessionEventType, session_event_from_dict from .types import ( MessageOptions, - PermissionHandler, SessionHooks, Tool, ToolHandler, UserInputHandler, UserInputRequest, UserInputResponse, + _PermissionHandlerFn, ) from .types import ( SessionEvent as SessionEventTypeAlias, @@ -73,12 +74,20 @@ def __init__(self, session_id: str, client: Any, workspace_path: Optional[str] = self._event_handlers_lock = threading.Lock() self._tool_handlers: dict[str, ToolHandler] = {} self._tool_handlers_lock = threading.Lock() - self._permission_handler: Optional[PermissionHandler] = None + self._permission_handler: Optional[_PermissionHandlerFn] = None self._permission_handler_lock = threading.Lock() self._user_input_handler: Optional[UserInputHandler] = None self._user_input_handler_lock = threading.Lock() self._hooks: Optional[SessionHooks] = None self._hooks_lock = threading.Lock() + self._rpc: Optional[SessionRpc] = None + + @property + def rpc(self) -> SessionRpc: + """Typed session-scoped RPC methods.""" + if self._rpc is None: + self._rpc = SessionRpc(self._client, self.session_id) + return self._rpc @property def workspace_path(self) -> Optional[str]: @@ -282,7 +291,7 @@ def _get_tool_handler(self, name: str) -> Optional[ToolHandler]: with self._tool_handlers_lock: return self._tool_handlers.get(name) - def _register_permission_handler(self, handler: Optional[PermissionHandler]) -> None: + def _register_permission_handler(self, handler: Optional[_PermissionHandlerFn]) -> None: """ Register a handler for permission requests. diff --git a/python/copilot/types.py b/python/copilot/types.py index 3cecbe64e..e89399777 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -73,6 +73,8 @@ class CopilotClientOptions(TypedDict, total=False): """Options for creating a CopilotClient""" cli_path: str # Path to the Copilot CLI executable (default: "copilot") + # Extra arguments to pass to the CLI executable (inserted before SDK-managed args) + cli_args: list[str] # Working directory for the CLI process (default: current process's cwd) cwd: str port: int # Port for the CLI server (TCP mode only, default: 0) @@ -184,12 +186,18 @@ class PermissionRequestResult(TypedDict, total=False): rules: list[Any] -PermissionHandler = Callable[ +_PermissionHandlerFn = Callable[ [PermissionRequest, dict[str, str]], Union[PermissionRequestResult, Awaitable[PermissionRequestResult]], ] +class PermissionHandler: + @staticmethod + def approve_all(request: Any, invocation: Any) -> dict: + return {"kind": "approved"} + + # ============================================================================ # User Input Request Types # ============================================================================ @@ -460,6 +468,9 @@ class SessionConfig(TypedDict, total=False): """Configuration for creating a session""" session_id: str # Optional custom session ID + # Client name to identify the application using the SDK. + # Included in the User-Agent header for API requests. + client_name: str model: str # Model to use for this session. Use client.list_models() to see available models. # Reasoning effort level for models that support it. # Only valid for models where capabilities.supports.reasoning_effort is True. @@ -471,7 +482,7 @@ class SessionConfig(TypedDict, total=False): # List of tool names to disable (ignored if available_tools is set) excluded_tools: list[str] # Handler for permission requests from the server - on_permission_request: PermissionHandler + on_permission_request: _PermissionHandlerFn # Handler for user input requests from the agent (enables ask_user tool) on_user_input_request: UserInputHandler # Hook handlers for intercepting session lifecycle events @@ -527,6 +538,9 @@ class ProviderConfig(TypedDict, total=False): class ResumeSessionConfig(TypedDict, total=False): """Configuration for resuming a session""" + # Client name to identify the application using the SDK. + # Included in the User-Agent header for API requests. + client_name: str # Model to use for this session. Can change the model when resuming. model: str tools: list[Tool] @@ -538,8 +552,8 @@ class ResumeSessionConfig(TypedDict, total=False): provider: ProviderConfig # Reasoning effort level for models that support it. reasoning_effort: ReasoningEffort - on_permission_request: PermissionHandler - # Handler for user input requests from the agent (enables ask_user tool) + on_permission_request: _PermissionHandlerFn + # Handler for user input requestsfrom the agent (enables ask_user tool) on_user_input_request: UserInputHandler # Hook handlers for intercepting session lifecycle events hooks: SessionHooks @@ -918,6 +932,61 @@ def to_dict(self) -> dict: return result +@dataclass +class SessionContext: + """Working directory context for a session""" + + cwd: str # Working directory where the session was created + gitRoot: str | None = None # Git repository root (if in a git repo) + repository: str | None = None # GitHub repository in "owner/repo" format + branch: str | None = None # Current git branch + + @staticmethod + def from_dict(obj: Any) -> SessionContext: + assert isinstance(obj, dict) + cwd = obj.get("cwd") + if cwd is None: + raise ValueError("Missing required field 'cwd' in SessionContext") + return SessionContext( + cwd=str(cwd), + gitRoot=obj.get("gitRoot"), + repository=obj.get("repository"), + branch=obj.get("branch"), + ) + + def to_dict(self) -> dict: + result: dict = {"cwd": self.cwd} + if self.gitRoot is not None: + result["gitRoot"] = self.gitRoot + if self.repository is not None: + result["repository"] = self.repository + if self.branch is not None: + result["branch"] = self.branch + return result + + +@dataclass +class SessionListFilter: + """Filter options for listing sessions""" + + cwd: str | None = None # Filter by exact cwd match + gitRoot: str | None = None # Filter by git root + repository: str | None = None # Filter by repository (owner/repo format) + branch: str | None = None # Filter by branch + + def to_dict(self) -> dict: + result: dict = {} + if self.cwd is not None: + result["cwd"] = self.cwd + if self.gitRoot is not None: + result["gitRoot"] = self.gitRoot + if self.repository is not None: + result["repository"] = self.repository + if self.branch is not None: + result["branch"] = self.branch + return result + + @dataclass class SessionMetadata: """Metadata about a session""" @@ -927,6 +996,7 @@ class SessionMetadata: modifiedTime: str # ISO 8601 timestamp when session was last modified isRemote: bool # Whether the session is remote summary: str | None = None # Optional summary of the session + context: SessionContext | None = None # Working directory context @staticmethod def from_dict(obj: Any) -> SessionMetadata: @@ -941,12 +1011,15 @@ def from_dict(obj: Any) -> SessionMetadata: f"startTime={startTime}, modifiedTime={modifiedTime}, isRemote={isRemote}" ) summary = obj.get("summary") + context_dict = obj.get("context") + context = SessionContext.from_dict(context_dict) if context_dict else None return SessionMetadata( sessionId=str(sessionId), startTime=str(startTime), modifiedTime=str(modifiedTime), isRemote=bool(isRemote), summary=summary, + context=context, ) def to_dict(self) -> dict: @@ -957,6 +1030,8 @@ def to_dict(self) -> dict: result["isRemote"] = self.isRemote if self.summary is not None: result["summary"] = self.summary + if self.context is not None: + result["context"] = self.context.to_dict() return result diff --git a/python/e2e/test_client.py b/python/e2e/test_client.py index aeaddbd9c..c18764e55 100644 --- a/python/e2e/test_client.py +++ b/python/e2e/test_client.py @@ -179,3 +179,37 @@ async def test_should_cache_models_list(self): await client.stop() finally: await client.force_stop() + + @pytest.mark.asyncio + async def test_should_report_error_with_stderr_when_cli_fails_to_start(self): + """Test that CLI startup errors include stderr output in the error message.""" + client = CopilotClient( + { + "cli_path": CLI_PATH, + "cli_args": ["--nonexistent-flag-for-testing"], + "use_stdio": True, + } + ) + + try: + with pytest.raises(RuntimeError) as exc_info: + await client.start() + + error_message = str(exc_info.value) + # Verify we get the stderr output in the error message + assert "stderr" in error_message, ( + f"Expected error to contain 'stderr', got: {error_message}" + ) + assert "nonexistent" in error_message, ( + f"Expected error to contain 'nonexistent', got: {error_message}" + ) + + # Verify subsequent calls also fail (don't hang) + with pytest.raises(Exception) as exc_info2: + session = await client.create_session() + await session.send("test") + # Error message varies by platform (EINVAL on Windows, EPIPE on Linux) + error_msg = str(exc_info2.value).lower() + assert "invalid" in error_msg or "pipe" in error_msg or "closed" in error_msg + finally: + await client.force_stop() diff --git a/python/e2e/test_hooks.py b/python/e2e/test_hooks.py index b64628e0a..8278fb33c 100644 --- a/python/e2e/test_hooks.py +++ b/python/e2e/test_hooks.py @@ -4,6 +4,8 @@ import pytest +from copilot import PermissionHandler + from .testharness import E2ETestContext from .testharness.helper import write_file @@ -21,7 +23,12 @@ async def on_pre_tool_use(input_data, invocation): # Allow the tool to run return {"permissionDecision": "allow"} - session = await ctx.client.create_session({"hooks": {"on_pre_tool_use": on_pre_tool_use}}) + session = await ctx.client.create_session( + { + "hooks": {"on_pre_tool_use": on_pre_tool_use}, + "on_permission_request": PermissionHandler.approve_all, + } + ) # Create a file for the model to read write_file(ctx.work_dir, "hello.txt", "Hello from the test!") @@ -49,7 +56,12 @@ async def on_post_tool_use(input_data, invocation): assert invocation["session_id"] == session.session_id return None - session = await ctx.client.create_session({"hooks": {"on_post_tool_use": on_post_tool_use}}) + session = await ctx.client.create_session( + { + "hooks": {"on_post_tool_use": on_post_tool_use}, + "on_permission_request": PermissionHandler.approve_all, + } + ) # Create a file for the model to read write_file(ctx.work_dir, "world.txt", "World from the test!") @@ -87,7 +99,8 @@ async def on_post_tool_use(input_data, invocation): "hooks": { "on_pre_tool_use": on_pre_tool_use, "on_post_tool_use": on_post_tool_use, - } + }, + "on_permission_request": PermissionHandler.approve_all, } ) @@ -118,7 +131,12 @@ async def on_pre_tool_use(input_data, invocation): # Deny all tool calls return {"permissionDecision": "deny"} - session = await ctx.client.create_session({"hooks": {"on_pre_tool_use": on_pre_tool_use}}) + session = await ctx.client.create_session( + { + "hooks": {"on_pre_tool_use": on_pre_tool_use}, + "on_permission_request": PermissionHandler.approve_all, + } + ) # Create a file original_content = "Original content that should not be modified" diff --git a/python/e2e/test_mcp_and_agents.py b/python/e2e/test_mcp_and_agents.py index bfff6c091..7ca4b8c2b 100644 --- a/python/e2e/test_mcp_and_agents.py +++ b/python/e2e/test_mcp_and_agents.py @@ -2,12 +2,19 @@ Tests for MCP servers and custom agents functionality """ +from pathlib import Path + import pytest -from copilot import CustomAgentConfig, MCPServerConfig +from copilot import CustomAgentConfig, MCPServerConfig, PermissionHandler from .testharness import E2ETestContext, get_final_assistant_message +TEST_MCP_SERVER = str( + (Path(__file__).parents[2] / "test" / "harness" / "test-mcp-server.mjs").resolve() +) +TEST_HARNESS_DIR = str((Path(__file__).parents[2] / "test" / "harness").resolve()) + pytestmark = pytest.mark.asyncio(loop_scope="module") @@ -65,6 +72,41 @@ async def test_should_accept_mcp_server_configuration_on_session_resume( await session2.destroy() + async def test_should_pass_literal_env_values_to_mcp_server_subprocess( + self, ctx: E2ETestContext + ): + """Test that env values are passed as literals to MCP server subprocess""" + mcp_servers: dict[str, MCPServerConfig] = { + "env-echo": { + "type": "local", + "command": "node", + "args": [TEST_MCP_SERVER], + "tools": ["*"], + "env": {"TEST_SECRET": "hunter2"}, + "cwd": TEST_HARNESS_DIR, + } + } + + session = await ctx.client.create_session( + { + "mcp_servers": mcp_servers, + "on_permission_request": PermissionHandler.approve_all, + } + ) + + assert session.session_id is not None + + message = await session.send_and_wait( + { + "prompt": "Use the env-echo/get_env tool to read the TEST_SECRET " + "environment variable. Reply with just the value, nothing else." + } + ) + assert message is not None + assert "hunter2" in message.data.content + + await session.destroy() + class TestCustomAgents: async def test_should_accept_custom_agent_configuration_on_session_create( diff --git a/python/e2e/test_permissions.py b/python/e2e/test_permissions.py index 7635219d4..80b69ebba 100644 --- a/python/e2e/test_permissions.py +++ b/python/e2e/test_permissions.py @@ -6,7 +6,7 @@ import pytest -from copilot import PermissionRequest, PermissionRequestResult +from copilot import PermissionHandler, PermissionRequest, PermissionRequestResult from .testharness import E2ETestContext from .testharness.helper import read_file, write_file @@ -68,6 +68,72 @@ def on_permission_request( await session.destroy() + async def test_should_deny_tool_operations_by_default_when_no_handler_is_provided( + self, ctx: E2ETestContext + ): + session = await ctx.client.create_session() + + denied_events = [] + done_event = asyncio.Event() + + def on_event(event): + if event.type.value == "tool.execution_complete" and event.data.success is False: + error = event.data.error + msg = ( + error + if isinstance(error, str) + else (getattr(error, "message", None) if error is not None else None) + ) + if msg and "Permission denied" in msg: + denied_events.append(event) + elif event.type.value == "session.idle": + done_event.set() + + session.on(on_event) + + await session.send({"prompt": "Run 'node --version'"}) + await asyncio.wait_for(done_event.wait(), timeout=60) + + assert len(denied_events) > 0 + + await session.destroy() + + async def test_should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume( + self, ctx: E2ETestContext + ): + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + session_id = session1.session_id + await session1.send_and_wait({"prompt": "What is 1+1?"}) + + session2 = await ctx.client.resume_session(session_id) + + denied_events = [] + done_event = asyncio.Event() + + def on_event(event): + if event.type.value == "tool.execution_complete" and event.data.success is False: + error = event.data.error + msg = ( + error + if isinstance(error, str) + else (getattr(error, "message", None) if error is not None else None) + ) + if msg and "Permission denied" in msg: + denied_events.append(event) + elif event.type.value == "session.idle": + done_event.set() + + session2.on(on_event) + + await session2.send({"prompt": "Run 'node --version'"}) + await asyncio.wait_for(done_event.wait(), timeout=60) + + assert len(denied_events) > 0 + + await session2.destroy() + async def test_should_work_without_permission_handler__default_behavior_( self, ctx: E2ETestContext ): diff --git a/python/e2e/test_rpc.py b/python/e2e/test_rpc.py new file mode 100644 index 000000000..da2ba3eb6 --- /dev/null +++ b/python/e2e/test_rpc.py @@ -0,0 +1,224 @@ +"""E2E RPC Tests""" + +import pytest + +from copilot import CopilotClient +from copilot.generated.rpc import PingParams + +from .testharness import CLI_PATH, E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestRpc: + @pytest.mark.asyncio + async def test_should_call_rpc_ping_with_typed_params(self): + """Test calling rpc.ping with typed params and result""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + + result = await client.rpc.ping(PingParams(message="typed rpc test")) + assert result.message == "pong: typed rpc test" + assert isinstance(result.timestamp, (int, float)) + + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_should_call_rpc_models_list(self): + """Test calling rpc.models.list with typed result""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + + auth_status = await client.get_auth_status() + if not auth_status.isAuthenticated: + await client.stop() + return + + result = await client.rpc.models.list() + assert result.models is not None + assert isinstance(result.models, list) + + await client.stop() + finally: + await client.force_stop() + + # account.getQuota is defined in schema but not yet implemented in CLI + @pytest.mark.skip(reason="account.getQuota not yet implemented in CLI") + @pytest.mark.asyncio + async def test_should_call_rpc_account_get_quota(self): + """Test calling rpc.account.getQuota when authenticated""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + + auth_status = await client.get_auth_status() + if not auth_status.isAuthenticated: + await client.stop() + return + + result = await client.rpc.account.get_quota() + assert result.quota_snapshots is not None + assert isinstance(result.quota_snapshots, dict) + + await client.stop() + finally: + await client.force_stop() + + +class TestSessionRpc: + # session.model.getCurrent is defined in schema but not yet implemented in CLI + @pytest.mark.skip(reason="session.model.getCurrent not yet implemented in CLI") + async def test_should_call_session_rpc_model_get_current(self, ctx: E2ETestContext): + """Test calling session.rpc.model.getCurrent""" + session = await ctx.client.create_session({"model": "claude-sonnet-4.5"}) + + result = await session.rpc.model.get_current() + assert result.model_id is not None + assert isinstance(result.model_id, str) + + # session.model.switchTo is defined in schema but not yet implemented in CLI + @pytest.mark.skip(reason="session.model.switchTo not yet implemented in CLI") + async def test_should_call_session_rpc_model_switch_to(self, ctx: E2ETestContext): + """Test calling session.rpc.model.switchTo""" + from copilot.generated.rpc import SessionModelSwitchToParams + + session = await ctx.client.create_session({"model": "claude-sonnet-4.5"}) + + # Get initial model + before = await session.rpc.model.get_current() + assert before.model_id is not None + + # Switch to a different model + result = await session.rpc.model.switch_to(SessionModelSwitchToParams(model_id="gpt-4.1")) + assert result.model_id == "gpt-4.1" + + # Verify the switch persisted + after = await session.rpc.model.get_current() + assert after.model_id == "gpt-4.1" + + @pytest.mark.asyncio + async def test_get_and_set_session_mode(self): + """Test getting and setting session mode""" + from copilot.generated.rpc import Mode, SessionModeSetParams + + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session({}) + + # Get initial mode (default should be interactive) + initial = await session.rpc.mode.get() + assert initial.mode == Mode.INTERACTIVE + + # Switch to plan mode + plan_result = await session.rpc.mode.set(SessionModeSetParams(mode=Mode.PLAN)) + assert plan_result.mode == Mode.PLAN + + # Verify mode persisted + after_plan = await session.rpc.mode.get() + assert after_plan.mode == Mode.PLAN + + # Switch back to interactive + interactive_result = await session.rpc.mode.set( + SessionModeSetParams(mode=Mode.INTERACTIVE) + ) + assert interactive_result.mode == Mode.INTERACTIVE + + await session.destroy() + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_read_update_and_delete_plan(self): + """Test reading, updating, and deleting plan""" + from copilot.generated.rpc import SessionPlanUpdateParams + + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session({}) + + # Initially plan should not exist + initial = await session.rpc.plan.read() + assert initial.exists is False + assert initial.content is None + + # Create/update plan + plan_content = "# Test Plan\n\n- Step 1\n- Step 2" + await session.rpc.plan.update(SessionPlanUpdateParams(content=plan_content)) + + # Verify plan exists and has correct content + after_update = await session.rpc.plan.read() + assert after_update.exists is True + assert after_update.content == plan_content + + # Delete plan + await session.rpc.plan.delete() + + # Verify plan is deleted + after_delete = await session.rpc.plan.read() + assert after_delete.exists is False + assert after_delete.content is None + + await session.destroy() + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_create_list_and_read_workspace_files(self): + """Test creating, listing, and reading workspace files""" + from copilot.generated.rpc import ( + SessionWorkspaceCreateFileParams, + SessionWorkspaceReadFileParams, + ) + + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session({}) + + # Initially no files + initial_files = await session.rpc.workspace.list_files() + assert initial_files.files == [] + + # Create a file + file_content = "Hello, workspace!" + await session.rpc.workspace.create_file( + SessionWorkspaceCreateFileParams(content=file_content, path="test.txt") + ) + + # List files + after_create = await session.rpc.workspace.list_files() + assert "test.txt" in after_create.files + + # Read file + read_result = await session.rpc.workspace.read_file( + SessionWorkspaceReadFileParams(path="test.txt") + ) + assert read_result.content == file_content + + # Create nested file + await session.rpc.workspace.create_file( + SessionWorkspaceCreateFileParams(content="Nested content", path="subdir/nested.txt") + ) + + after_nested = await session.rpc.workspace.list_files() + assert "test.txt" in after_nested.files + assert any("nested.txt" in f for f in after_nested.files) + + await session.destroy() + await client.stop() + finally: + await client.force_stop() diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index f2e545ede..0998298f4 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -4,7 +4,7 @@ import pytest -from copilot import CopilotClient +from copilot import CopilotClient, PermissionHandler from copilot.types import Tool from .testharness import E2ETestContext, get_final_assistant_message, get_next_event_of_type @@ -220,6 +220,13 @@ async def test_should_list_sessions(self, ctx: E2ETestContext): assert isinstance(session_data.modifiedTime, str) assert isinstance(session_data.isRemote, bool) + # Verify context field is present + for session_data in sessions: + assert hasattr(session_data, "context") + if session_data.context is not None: + assert hasattr(session_data.context, "cwd") + assert isinstance(session_data.context.cwd, str) + async def test_should_delete_session(self, ctx: E2ETestContext): import asyncio @@ -326,7 +333,9 @@ async def test_should_resume_session_with_custom_provider(self, ctx: E2ETestCont async def test_should_abort_a_session(self, ctx: E2ETestContext): import asyncio - session = await ctx.client.create_session() + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) # Set up event listeners BEFORE sending to avoid race conditions wait_for_tool_start = asyncio.create_task( diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index 2e024887c..10e61cf15 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -5,7 +5,7 @@ import pytest from pydantic import BaseModel, Field -from copilot import ToolInvocation, define_tool +from copilot import PermissionHandler, ToolInvocation, define_tool from .testharness import E2ETestContext, get_final_assistant_message @@ -18,7 +18,9 @@ async def test_invokes_built_in_tools(self, ctx: E2ETestContext): with open(readme_path, "w") as f: f.write("# ELIZA, the only chatbot you'll ever need") - session = await ctx.client.create_session() + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) await session.send({"prompt": "What's the first line of README.md in this directory?"}) assistant_message = await get_final_assistant_message(session) diff --git a/python/e2e/testharness/context.py b/python/e2e/testharness/context.py index 533ee87e7..4417f567d 100644 --- a/python/e2e/testharness/context.py +++ b/python/e2e/testharness/context.py @@ -17,7 +17,14 @@ def get_cli_path_for_tests() -> str: - """Get CLI path for E2E tests. Uses node_modules CLI during development.""" + """Get CLI path for E2E tests. + + Uses COPILOT_CLI_PATH env var if set, otherwise node_modules CLI. + """ + env_path = os.environ.get("COPILOT_CLI_PATH") + if env_path and Path(env_path).exists(): + return str(Path(env_path).resolve()) + # Look for CLI in sibling nodejs directory's node_modules base_path = Path(__file__).parents[3] full_path = base_path / "nodejs" / "node_modules" / "@github" / "copilot" / "index.js" diff --git a/python/pyproject.toml b/python/pyproject.toml index b902b050a..6c4d3e723 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -8,14 +8,14 @@ version = "0.1.0" description = "Python SDK for GitHub Copilot CLI" readme = "README.md" requires-python = ">=3.9" -license = {text = "MIT"} +license = "MIT" +# license-files is set by scripts/build-wheels.mjs for bundled CLI wheels authors = [ {name = "GitHub", email = "opensource@github.com"} ] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -32,10 +32,6 @@ dependencies = [ Homepage = "https://github.com/github/copilot-sdk" Repository = "https://github.com/github/copilot-sdk" -[tool.setuptools.packages.find] -where = ["."] -include = ["copilot*"] - [project.optional-dependencies] dev = [ "ruff>=0.1.0", @@ -46,6 +42,9 @@ dev = [ "httpx>=0.24.0", ] +[tool.setuptools] +packages = ["copilot"] + [tool.ruff] line-length = 100 target-version = "py39" diff --git a/python/samples/chat.py b/python/samples/chat.py new file mode 100644 index 000000000..eb781e4e2 --- /dev/null +++ b/python/samples/chat.py @@ -0,0 +1,45 @@ +import asyncio + +from copilot import CopilotClient, PermissionHandler + +BLUE = "\033[34m" +RESET = "\033[0m" + + +async def main(): + client = CopilotClient() + await client.start() + session = await client.create_session( + { + "on_permission_request": PermissionHandler.approve_all, + } + ) + + def on_event(event): + output = None + if event.type.value == "assistant.reasoning": + output = f"[reasoning: {event.data.content}]" + elif event.type.value == "tool.execution_start": + output = f"[tool: {event.data.tool_name}]" + if output: + print(f"{BLUE}{output}{RESET}") + + session.on(on_event) + + print("Chat with Copilot (Ctrl+C to exit)\n") + + while True: + user_input = input("You: ").strip() + if not user_input: + continue + print() + + reply = await session.send_and_wait({"prompt": user_input}) + print(f"\nAssistant: {reply.data.content if reply else None}\n") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nBye!") diff --git a/python/scripts/build-wheels.mjs b/python/scripts/build-wheels.mjs index 5dac70254..c9d49b414 100644 --- a/python/scripts/build-wheels.mjs +++ b/python/scripts/build-wheels.mjs @@ -36,9 +36,10 @@ const pythonDir = dirname(__dirname); const repoRoot = dirname(pythonDir); // Platform mappings: npm package suffix -> [wheel platform tag, binary name] +// Based on Node 24.11 binaries being included in the wheels const PLATFORMS = { - "linux-x64": ["manylinux_2_17_x86_64", "copilot"], - "linux-arm64": ["manylinux_2_17_aarch64", "copilot"], + "linux-x64": ["manylinux_2_28_x86_64", "copilot"], + "linux-arm64": ["manylinux_2_28_aarch64", "copilot"], "darwin-x64": ["macosx_10_9_x86_64", "copilot"], "darwin-arm64": ["macosx_11_0_arm64", "copilot"], "win32-x64": ["win_amd64", "copilot.exe"], @@ -180,13 +181,13 @@ async function buildWheel(platform, pkgVersion, cliVersion, outputDir, licensePa // Create __init__.py writeFileSync(join(binDir, "__init__.py"), '"""Bundled Copilot CLI binary."""\n'); - // Copy and modify pyproject.toml - replace license reference with file + // Copy and modify pyproject.toml for bundled CLI wheel let pyprojectContent = readFileSync(join(pythonDir, "pyproject.toml"), "utf-8"); - // Replace the license specification with file reference + // Update SPDX expression and add license-files for both SDK and bundled CLI licenses pyprojectContent = pyprojectContent.replace( - 'license = {text = "MIT"}', - 'license = {file = "CLI-LICENSE.md"}' + 'license = "MIT"', + 'license = "MIT AND LicenseRef-Copilot-CLI"\nlicense-files = ["LICENSE", "CLI-LICENSE.md"]' ); // Add package-data configuration @@ -202,6 +203,9 @@ async function buildWheel(platform, pkgVersion, cliVersion, outputDir, licensePa cpSync(join(pythonDir, "README.md"), join(buildDir, "README.md")); } + // Copy SDK LICENSE + cpSync(join(repoRoot, "LICENSE"), join(buildDir, "LICENSE")); + // Copy CLI LICENSE cpSync(licensePath, join(buildDir, "CLI-LICENSE.md")); @@ -253,6 +257,11 @@ with tempfile.TemporaryDirectory() as tmpdir: with zipfile.ZipFile(src_wheel, 'r') as zf: zf.extractall(tmpdir) + # Restore executable bit on the CLI binary (setuptools strips it) + for bin_path in (tmpdir / 'copilot' / 'bin').iterdir(): + if bin_path.name in ('copilot', 'copilot.exe'): + bin_path.chmod(0o755) + # Find and update WHEEL file wheel_info_dirs = list(tmpdir.glob('*.dist-info')) if not wheel_info_dirs: diff --git a/python/setup.py b/python/setup.py deleted file mode 100644 index cef011487..000000000 --- a/python/setup.py +++ /dev/null @@ -1,11 +0,0 @@ -from setuptools import find_packages, setup - -setup( - name="github-copilot-sdk", - version="0.1.0", - packages=find_packages(), - install_requires=[ - "typing-extensions>=4.0.0", - ], - python_requires=">=3.8", -) diff --git a/python/test-requirements.txt b/python/test-requirements.txt deleted file mode 100644 index d2cd94055..000000000 --- a/python/test-requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -pytest>=7.0.0 -pytest-asyncio>=0.21.0 -typing-extensions>=4.0.0 -python-dateutil >=2.9.0 -httpx>=0.25.0 diff --git a/python/test_client.py b/python/test_client.py index 7b4af8c0f..0bc99ea69 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -147,3 +147,45 @@ def test_use_logged_in_user_with_cli_url_raises(self): CopilotClient( {"cli_url": "localhost:8080", "use_logged_in_user": False, "log_level": "error"} ) + + +class TestSessionConfigForwarding: + @pytest.mark.asyncio + async def test_create_session_forwards_client_name(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + await client.create_session({"client_name": "my-app"}) + assert captured["session.create"]["clientName"] == "my-app" + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_forwards_client_name(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + + try: + session = await client.create_session() + + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + await client.resume_session(session.session_id, {"client_name": "my-app"}) + assert captured["session.resume"]["clientName"] == "my-app" + finally: + await client.force_stop() diff --git a/python/uv.lock b/python/uv.lock deleted file mode 100644 index 35134a0b0..000000000 --- a/python/uv.lock +++ /dev/null @@ -1,579 +0,0 @@ -version = 1 -revision = 2 -requires-python = ">=3.9" -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version < '3.10'", -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, -] - -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, -] - -[[package]] -name = "certifi" -version = "2025.11.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - -[[package]] -name = "github-copilot-sdk" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "pydantic" }, - { name = "python-dateutil" }, - { name = "typing-extensions" }, -] - -[package.optional-dependencies] -dev = [ - { name = "httpx" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest-timeout" }, - { name = "ruff" }, - { name = "ty" }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.24.0" }, - { name = "pydantic", specifier = ">=2.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, - { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.0.0" }, - { name = "python-dateutil", specifier = ">=2.9.0.post0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, - { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.2" }, - { name = "typing-extensions", specifier = ">=4.0.0" }, -] -provides-extras = ["dev"] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, - { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, - { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, - { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, - { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, - { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, - { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/54/db/160dffb57ed9a3705c4cbcbff0ac03bdae45f1ca7d58ab74645550df3fbd/pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", size = 2107999, upload-time = "2025-11-04T13:42:03.885Z" }, - { url = "https://files.pythonhosted.org/packages/a3/7d/88e7de946f60d9263cc84819f32513520b85c0f8322f9b8f6e4afc938383/pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", size = 1929745, upload-time = "2025-11-04T13:42:06.075Z" }, - { url = "https://files.pythonhosted.org/packages/d5/c2/aef51e5b283780e85e99ff19db0f05842d2d4a8a8cd15e63b0280029b08f/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", size = 1920220, upload-time = "2025-11-04T13:42:08.457Z" }, - { url = "https://files.pythonhosted.org/packages/c7/97/492ab10f9ac8695cd76b2fdb24e9e61f394051df71594e9bcc891c9f586e/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", size = 2067296, upload-time = "2025-11-04T13:42:10.817Z" }, - { url = "https://files.pythonhosted.org/packages/ec/23/984149650e5269c59a2a4c41d234a9570adc68ab29981825cfaf4cfad8f4/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", size = 2231548, upload-time = "2025-11-04T13:42:13.843Z" }, - { url = "https://files.pythonhosted.org/packages/71/0c/85bcbb885b9732c28bec67a222dbed5ed2d77baee1f8bba2002e8cd00c5c/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", size = 2362571, upload-time = "2025-11-04T13:42:16.208Z" }, - { url = "https://files.pythonhosted.org/packages/c0/4a/412d2048be12c334003e9b823a3fa3d038e46cc2d64dd8aab50b31b65499/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", size = 2068175, upload-time = "2025-11-04T13:42:18.911Z" }, - { url = "https://files.pythonhosted.org/packages/73/f4/c58b6a776b502d0a5540ad02e232514285513572060f0d78f7832ca3c98b/pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", size = 2177203, upload-time = "2025-11-04T13:42:22.578Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ae/f06ea4c7e7a9eead3d165e7623cd2ea0cb788e277e4f935af63fc98fa4e6/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", size = 2148191, upload-time = "2025-11-04T13:42:24.89Z" }, - { url = "https://files.pythonhosted.org/packages/c1/57/25a11dcdc656bf5f8b05902c3c2934ac3ea296257cc4a3f79a6319e61856/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", size = 2343907, upload-time = "2025-11-04T13:42:27.683Z" }, - { url = "https://files.pythonhosted.org/packages/96/82/e33d5f4933d7a03327c0c43c65d575e5919d4974ffc026bc917a5f7b9f61/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", size = 2322174, upload-time = "2025-11-04T13:42:30.776Z" }, - { url = "https://files.pythonhosted.org/packages/81/45/4091be67ce9f469e81656f880f3506f6a5624121ec5eb3eab37d7581897d/pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", size = 1990353, upload-time = "2025-11-04T13:42:33.111Z" }, - { url = "https://files.pythonhosted.org/packages/44/8a/a98aede18db6e9cd5d66bcacd8a409fcf8134204cdede2e7de35c5a2c5ef/pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", size = 2015698, upload-time = "2025-11-04T13:42:35.484Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, - { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, - { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, - { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, - { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, - { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, - { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pluggy", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, - { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, -] - -[[package]] -name = "pytest-timeout" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - -[[package]] -name = "ruff" -version = "0.14.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/1b/ab712a9d5044435be8e9a2beb17cbfa4c241aa9b5e4413febac2a8b79ef2/ruff-0.14.9.tar.gz", hash = "sha256:35f85b25dd586381c0cc053f48826109384c81c00ad7ef1bd977bfcc28119d5b", size = 5809165, upload-time = "2025-12-11T21:39:47.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/1c/d1b1bba22cffec02351c78ab9ed4f7d7391876e12720298448b29b7229c1/ruff-0.14.9-py3-none-linux_armv6l.whl", hash = "sha256:f1ec5de1ce150ca6e43691f4a9ef5c04574ad9ca35c8b3b0e18877314aba7e75", size = 13576541, upload-time = "2025-12-11T21:39:14.806Z" }, - { url = "https://files.pythonhosted.org/packages/94/ab/ffe580e6ea1fca67f6337b0af59fc7e683344a43642d2d55d251ff83ceae/ruff-0.14.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ed9d7417a299fc6030b4f26333bf1117ed82a61ea91238558c0268c14e00d0c2", size = 13779363, upload-time = "2025-12-11T21:39:20.29Z" }, - { url = "https://files.pythonhosted.org/packages/7d/f8/2be49047f929d6965401855461e697ab185e1a6a683d914c5c19c7962d9e/ruff-0.14.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d5dc3473c3f0e4a1008d0ef1d75cee24a48e254c8bed3a7afdd2b4392657ed2c", size = 12925292, upload-time = "2025-12-11T21:39:38.757Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e9/08840ff5127916bb989c86f18924fd568938b06f58b60e206176f327c0fe/ruff-0.14.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84bf7c698fc8f3cb8278830fb6b5a47f9bcc1ed8cb4f689b9dd02698fa840697", size = 13362894, upload-time = "2025-12-11T21:39:02.524Z" }, - { url = "https://files.pythonhosted.org/packages/31/1c/5b4e8e7750613ef43390bb58658eaf1d862c0cc3352d139cd718a2cea164/ruff-0.14.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa733093d1f9d88a5d98988d8834ef5d6f9828d03743bf5e338bf980a19fce27", size = 13311482, upload-time = "2025-12-11T21:39:17.51Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3a/459dce7a8cb35ba1ea3e9c88f19077667a7977234f3b5ab197fad240b404/ruff-0.14.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a1cfb04eda979b20c8c19550c8b5f498df64ff8da151283311ce3199e8b3648", size = 14016100, upload-time = "2025-12-11T21:39:41.948Z" }, - { url = "https://files.pythonhosted.org/packages/a6/31/f064f4ec32524f9956a0890fc6a944e5cf06c63c554e39957d208c0ffc45/ruff-0.14.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1e5cb521e5ccf0008bd74d5595a4580313844a42b9103b7388eca5a12c970743", size = 15477729, upload-time = "2025-12-11T21:39:23.279Z" }, - { url = "https://files.pythonhosted.org/packages/7a/6d/f364252aad36ccd443494bc5f02e41bf677f964b58902a17c0b16c53d890/ruff-0.14.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd429a8926be6bba4befa8cdcf3f4dd2591c413ea5066b1e99155ed245ae42bb", size = 15122386, upload-time = "2025-12-11T21:39:33.125Z" }, - { url = "https://files.pythonhosted.org/packages/20/02/e848787912d16209aba2799a4d5a1775660b6a3d0ab3944a4ccc13e64a02/ruff-0.14.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab208c1b7a492e37caeaf290b1378148f75e13c2225af5d44628b95fd7834273", size = 14497124, upload-time = "2025-12-11T21:38:59.33Z" }, - { url = "https://files.pythonhosted.org/packages/f3/51/0489a6a5595b7760b5dbac0dd82852b510326e7d88d51dbffcd2e07e3ff3/ruff-0.14.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72034534e5b11e8a593f517b2f2f2b273eb68a30978c6a2d40473ad0aaa4cb4a", size = 14195343, upload-time = "2025-12-11T21:39:44.866Z" }, - { url = "https://files.pythonhosted.org/packages/f6/53/3bb8d2fa73e4c2f80acc65213ee0830fa0c49c6479313f7a68a00f39e208/ruff-0.14.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:712ff04f44663f1b90a1195f51525836e3413c8a773574a7b7775554269c30ed", size = 14346425, upload-time = "2025-12-11T21:39:05.927Z" }, - { url = "https://files.pythonhosted.org/packages/ad/04/bdb1d0ab876372da3e983896481760867fc84f969c5c09d428e8f01b557f/ruff-0.14.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a111fee1db6f1d5d5810245295527cda1d367c5aa8f42e0fca9a78ede9b4498b", size = 13258768, upload-time = "2025-12-11T21:39:08.691Z" }, - { url = "https://files.pythonhosted.org/packages/40/d9/8bf8e1e41a311afd2abc8ad12be1b6c6c8b925506d9069b67bb5e9a04af3/ruff-0.14.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8769efc71558fecc25eb295ddec7d1030d41a51e9dcf127cbd63ec517f22d567", size = 13326939, upload-time = "2025-12-11T21:39:53.842Z" }, - { url = "https://files.pythonhosted.org/packages/f4/56/a213fa9edb6dd849f1cfbc236206ead10913693c72a67fb7ddc1833bf95d/ruff-0.14.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:347e3bf16197e8a2de17940cd75fd6491e25c0aa7edf7d61aa03f146a1aa885a", size = 13578888, upload-time = "2025-12-11T21:39:35.988Z" }, - { url = "https://files.pythonhosted.org/packages/33/09/6a4a67ffa4abae6bf44c972a4521337ffce9cbc7808faadede754ef7a79c/ruff-0.14.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7715d14e5bccf5b660f54516558aa94781d3eb0838f8e706fb60e3ff6eff03a8", size = 14314473, upload-time = "2025-12-11T21:39:50.78Z" }, - { url = "https://files.pythonhosted.org/packages/12/0d/15cc82da5d83f27a3c6b04f3a232d61bc8c50d38a6cd8da79228e5f8b8d6/ruff-0.14.9-py3-none-win32.whl", hash = "sha256:df0937f30aaabe83da172adaf8937003ff28172f59ca9f17883b4213783df197", size = 13202651, upload-time = "2025-12-11T21:39:26.628Z" }, - { url = "https://files.pythonhosted.org/packages/32/f7/c78b060388eefe0304d9d42e68fab8cffd049128ec466456cef9b8d4f06f/ruff-0.14.9-py3-none-win_amd64.whl", hash = "sha256:c0b53a10e61df15a42ed711ec0bda0c582039cf6c754c49c020084c55b5b0bc2", size = 14702079, upload-time = "2025-12-11T21:39:11.954Z" }, - { url = "https://files.pythonhosted.org/packages/26/09/7a9520315decd2334afa65ed258fed438f070e31f05a2e43dd480a5e5911/ruff-0.14.9-py3-none-win_arm64.whl", hash = "sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84", size = 13744730, upload-time = "2025-12-11T21:39:29.659Z" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - -[[package]] -name = "tomli" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, - { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, - { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, - { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, - { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, - { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, - { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, - { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, - { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, - { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, -] - -[[package]] -name = "ty" -version = "0.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/e5/15b6aceefcd64b53997fe2002b6fa055f0b1afd23ff6fc3f55f3da944530/ty-0.0.2.tar.gz", hash = "sha256:e02dc50b65dc58d6cb8e8b0d563833f81bf03ed8a7d0b15c6396d486489a7e1d", size = 4762024, upload-time = "2025-12-16T20:13:41.07Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/86/65d4826677d966cf226662767a4a597ebb4b02c432f413673c8d5d3d1ce8/ty-0.0.2-py3-none-linux_armv6l.whl", hash = "sha256:0954a0e0b6f7e06229dd1da3a9989ee9b881a26047139a88eb7c134c585ad22e", size = 9771409, upload-time = "2025-12-16T20:13:28.964Z" }, - { url = "https://files.pythonhosted.org/packages/d4/bc/6ab06b7c109cec608c24ea182cc8b4714e746a132f70149b759817092665/ty-0.0.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d6044b491d66933547033cecc87cb7eb599ba026a3ef347285add6b21107a648", size = 9580025, upload-time = "2025-12-16T20:13:34.507Z" }, - { url = "https://files.pythonhosted.org/packages/54/de/d826804e304b2430f17bb27ae15bcf02380e7f67f38b5033047e3d2523e6/ty-0.0.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbca7f08e671a35229f6f400d73da92e2dc0a440fba53a74fe8233079a504358", size = 9098660, upload-time = "2025-12-16T20:13:01.278Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8e/5cd87944ceee02bb0826f19ced54e30c6bb971e985a22768f6be6b1a042f/ty-0.0.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3abd61153dac0b93b284d305e6f96085013a25c3a7ab44e988d24f0a5fcce729", size = 9567693, upload-time = "2025-12-16T20:13:12.559Z" }, - { url = "https://files.pythonhosted.org/packages/c6/b1/062aab2c62c5ae01c05d27b97ba022d9ff66f14a3cb9030c5ad1dca797ec/ty-0.0.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:21a9f28caafb5742e7d594104e2fe2ebd64590da31aed4745ae8bc5be67a7b85", size = 9556471, upload-time = "2025-12-16T20:13:07.771Z" }, - { url = "https://files.pythonhosted.org/packages/0e/07/856f6647a9dd6e36560d182d35d3b5fb21eae98a8bfb516cd879d0e509f3/ty-0.0.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3ec63fd23ab48e0f838fb54a47ec362a972ee80979169a7edfa6f5c5034849d", size = 9971914, upload-time = "2025-12-16T20:13:18.852Z" }, - { url = "https://files.pythonhosted.org/packages/2e/82/c2e3957dbf33a23f793a9239cfd8bd04b6defd999bd0f6e74d6a5afb9f42/ty-0.0.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e5e2e0293a259c9a53f668c9c13153cc2f1403cb0fe2b886ca054be4ac76517c", size = 10840905, upload-time = "2025-12-16T20:13:37.098Z" }, - { url = "https://files.pythonhosted.org/packages/3b/17/49bd74e3d577e6c88b8074581b7382f532a9d40552cc7c48ceaa83f1d950/ty-0.0.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fd2511ac02a83d0dc45d4570c7e21ec0c919be7a7263bad9914800d0cde47817", size = 10570251, upload-time = "2025-12-16T20:13:10.319Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9b/26741834069722033a1a0963fcbb63ea45925c6697357e64e361753c6166/ty-0.0.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c482bfbfb8ad18b2e62427d02a0c934ac510c414188a3cf00e16b8acc35482f0", size = 10369078, upload-time = "2025-12-16T20:13:20.851Z" }, - { url = "https://files.pythonhosted.org/packages/94/fc/1d34ec891900d9337169ff9f8252fcaa633ae5c4d36b67effd849ed4f9ac/ty-0.0.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb514711eed3f56d7a130d4885f4b5d8e490fdcd2adac098e5cf175573a0dda3", size = 10121064, upload-time = "2025-12-16T20:13:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/e5/02/e640325956172355ef8deb9b08d991f229230bf9d07f1dbda8c6665a3a43/ty-0.0.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2c37fa26c39e9fbed7c73645ba721968ab44f28b2bfe2f79a4e15965a1c426f", size = 9553817, upload-time = "2025-12-16T20:13:27.057Z" }, - { url = "https://files.pythonhosted.org/packages/35/13/c93d579ece84895da9b0aae5d34d84100bbff63ad9f60c906a533a087175/ty-0.0.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:13b264833ac5f3b214693fca38e380e78ee7327e09beaa5ff2e47d75fcab9692", size = 9577512, upload-time = "2025-12-16T20:13:16.956Z" }, - { url = "https://files.pythonhosted.org/packages/85/53/93ab1570adc799cd9120ea187d5b4c00d821e86eca069943b179fe0d3e83/ty-0.0.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:08658d6dbbf8bdef80c0a77eda56a22ab6737002ba129301b7bbd36bcb7acd75", size = 9692726, upload-time = "2025-12-16T20:13:31.169Z" }, - { url = "https://files.pythonhosted.org/packages/9a/07/5fff5335858a14196776207d231c32e23e48a5c912a7d52c80e7a3fa6f8f/ty-0.0.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4a21b5b012061cb13d47edfff6be70052694308dba633b4c819b70f840e6c158", size = 10213996, upload-time = "2025-12-16T20:13:14.606Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d3/896b1439ab765c57a8d732f73c105ec41142c417a582600638385c2bee85/ty-0.0.2-py3-none-win32.whl", hash = "sha256:d773fdad5d2b30f26313204e6b191cdd2f41ab440a6c241fdb444f8c6593c288", size = 9204906, upload-time = "2025-12-16T20:13:25.099Z" }, - { url = "https://files.pythonhosted.org/packages/5d/0a/f30981e7d637f78e3d08e77d63b818752d23db1bc4b66f9e82e2cb3d34f8/ty-0.0.2-py3-none-win_amd64.whl", hash = "sha256:d1c9ac78a8aa60d0ce89acdccf56c3cc0fcb2de07f1ecf313754d83518e8e8c5", size = 10066640, upload-time = "2025-12-16T20:13:04.045Z" }, - { url = "https://files.pythonhosted.org/packages/5a/c4/97958503cf62bfb7908d2a77b03b91a20499a7ff405f5a098c4989589f34/ty-0.0.2-py3-none-win_arm64.whl", hash = "sha256:fbdef644ade0cd4420c4ec14b604b7894cefe77bfd8659686ac2f6aba9d1a306", size = 9572022, upload-time = "2025-12-16T20:13:39.189Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] diff --git a/scripts/codegen/.gitignore b/scripts/codegen/.gitignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/scripts/codegen/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts new file mode 100644 index 000000000..e5e0fcf9a --- /dev/null +++ b/scripts/codegen/csharp.ts @@ -0,0 +1,791 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * C# code generator for session-events and RPC types. + */ + +import { execFile } from "child_process"; +import fs from "fs/promises"; +import path from "path"; +import { promisify } from "util"; +import type { JSONSchema7 } from "json-schema"; +import { + getSessionEventsSchemaPath, + getApiSchemaPath, + writeGeneratedFile, + isRpcMethod, + EXCLUDED_EVENT_TYPES, + REPO_ROOT, + type ApiSchema, + type RpcMethod, +} from "./utils.js"; + +const execFileAsync = promisify(execFile); + +// ── C# utilities ──────────────────────────────────────────────────────────── + +function toPascalCase(name: string): string { + if (name.includes("_")) { + return name.split("_").map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(""); + } + return name.charAt(0).toUpperCase() + name.slice(1); +} + +function typeToClassName(typeName: string): string { + return typeName.split(/[._]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(""); +} + +function toPascalCaseEnumMember(value: string): string { + return value.split(/[-_.]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(""); +} + +async function formatCSharpFile(filePath: string): Promise { + try { + const projectFile = path.join(REPO_ROOT, "dotnet/src/GitHub.Copilot.SDK.csproj"); + await execFileAsync("dotnet", ["format", projectFile, "--include", filePath]); + console.log(` ✓ Formatted with dotnet format`); + } catch { + // dotnet format not available, skip + } +} + +function collectRpcMethods(node: Record): RpcMethod[] { + const results: RpcMethod[] = []; + for (const value of Object.values(node)) { + if (isRpcMethod(value)) { + results.push(value); + } else if (typeof value === "object" && value !== null) { + results.push(...collectRpcMethods(value as Record)); + } + } + return results; +} + +function schemaTypeToCSharp(schema: JSONSchema7, required: boolean, knownTypes: Map): string { + if (schema.anyOf) { + const nonNull = schema.anyOf.filter((s) => typeof s === "object" && s.type !== "null"); + if (nonNull.length === 1 && typeof nonNull[0] === "object") { + // Pass required=true to get the base type, then add "?" for nullable + return schemaTypeToCSharp(nonNull[0] as JSONSchema7, true, knownTypes) + "?"; + } + } + if (schema.$ref) { + const refName = schema.$ref.split("/").pop()!; + return knownTypes.get(refName) || refName; + } + const type = schema.type; + const format = schema.format; + // Handle type: ["string", "null"] patterns (nullable string) + if (Array.isArray(type)) { + const nonNullTypes = type.filter((t) => t !== "null"); + if (nonNullTypes.length === 1 && nonNullTypes[0] === "string") { + if (format === "uuid") return "Guid?"; + if (format === "date-time") return "DateTimeOffset?"; + return "string?"; + } + } + if (type === "string") { + if (format === "uuid") return required ? "Guid" : "Guid?"; + if (format === "date-time") return required ? "DateTimeOffset" : "DateTimeOffset?"; + return required ? "string" : "string?"; + } + if (type === "number" || type === "integer") return required ? "double" : "double?"; + if (type === "boolean") return required ? "bool" : "bool?"; + if (type === "array") { + const items = schema.items as JSONSchema7 | undefined; + const itemType = items ? schemaTypeToCSharp(items, true, knownTypes) : "object"; + return required ? `${itemType}[]` : `${itemType}[]?`; + } + if (type === "object") { + if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + const valueType = schemaTypeToCSharp(schema.additionalProperties as JSONSchema7, true, knownTypes); + return required ? `Dictionary` : `Dictionary?`; + } + return required ? "object" : "object?"; + } + return required ? "object" : "object?"; +} + +const COPYRIGHT = `/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/`; + +// ══════════════════════════════════════════════════════════════════════════════ +// SESSION EVENTS +// ══════════════════════════════════════════════════════════════════════════════ + +interface EventVariant { + typeName: string; + className: string; + dataClassName: string; + dataSchema: JSONSchema7; +} + +let generatedEnums = new Map(); + +function getOrCreateEnum(parentClassName: string, propName: string, values: string[], enumOutput: string[]): string { + const valuesKey = [...values].sort().join("|"); + for (const [, existing] of generatedEnums) { + if ([...existing.values].sort().join("|") === valuesKey) return existing.enumName; + } + const enumName = `${parentClassName}${propName}`; + generatedEnums.set(enumName, { enumName, values }); + + const lines = [`[JsonConverter(typeof(JsonStringEnumConverter<${enumName}>))]`, `public enum ${enumName}`, `{`]; + for (const value of values) { + lines.push(` [JsonStringEnumMemberName("${value}")]`, ` ${toPascalCaseEnumMember(value)},`); + } + lines.push(`}`, ""); + enumOutput.push(lines.join("\n")); + return enumName; +} + +function extractEventVariants(schema: JSONSchema7): EventVariant[] { + const sessionEvent = schema.definitions?.SessionEvent as JSONSchema7; + if (!sessionEvent?.anyOf) throw new Error("Schema must have SessionEvent definition with anyOf"); + + return sessionEvent.anyOf + .map((variant) => { + if (typeof variant !== "object" || !variant.properties) throw new Error("Invalid variant"); + const typeSchema = variant.properties.type as JSONSchema7; + const typeName = typeSchema?.const as string; + if (!typeName) throw new Error("Variant must have type.const"); + const baseName = typeToClassName(typeName); + return { + typeName, + className: `${baseName}Event`, + dataClassName: `${baseName}Data`, + dataSchema: variant.properties.data as JSONSchema7, + }; + }) + .filter((v) => !EXCLUDED_EVENT_TYPES.has(v.typeName)); +} + +/** + * Find a discriminator property shared by all variants in an anyOf. + */ +function findDiscriminator(variants: JSONSchema7[]): { property: string; mapping: Map } | null { + if (variants.length === 0) return null; + const firstVariant = variants[0]; + if (!firstVariant.properties) return null; + + for (const [propName, propSchema] of Object.entries(firstVariant.properties)) { + if (typeof propSchema !== "object") continue; + const schema = propSchema as JSONSchema7; + if (schema.const === undefined) continue; + + const mapping = new Map(); + let isValidDiscriminator = true; + + for (const variant of variants) { + if (!variant.properties) { isValidDiscriminator = false; break; } + const variantProp = variant.properties[propName]; + if (typeof variantProp !== "object") { isValidDiscriminator = false; break; } + const variantSchema = variantProp as JSONSchema7; + if (variantSchema.const === undefined) { isValidDiscriminator = false; break; } + mapping.set(String(variantSchema.const), variant); + } + + if (isValidDiscriminator && mapping.size === variants.length) { + return { property: propName, mapping }; + } + } + return null; +} + +/** + * Generate a polymorphic base class and derived classes for a discriminated union. + */ +function generatePolymorphicClasses( + baseClassName: string, + discriminatorProperty: string, + variants: JSONSchema7[], + knownTypes: Map, + nestedClasses: Map, + enumOutput: string[] +): string { + const lines: string[] = []; + const discriminatorInfo = findDiscriminator(variants)!; + + lines.push(`[JsonPolymorphic(`); + lines.push(` TypeDiscriminatorPropertyName = "${discriminatorProperty}",`); + lines.push(` UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)]`); + + for (const [constValue] of discriminatorInfo.mapping) { + const derivedClassName = `${baseClassName}${toPascalCase(constValue)}`; + lines.push(`[JsonDerivedType(typeof(${derivedClassName}), "${constValue}")]`); + } + + lines.push(`public partial class ${baseClassName}`); + lines.push(`{`); + lines.push(` [JsonPropertyName("${discriminatorProperty}")]`); + lines.push(` public virtual string ${toPascalCase(discriminatorProperty)} { get; set; } = string.Empty;`); + lines.push(`}`); + lines.push(""); + + for (const [constValue, variant] of discriminatorInfo.mapping) { + const derivedClassName = `${baseClassName}${toPascalCase(constValue)}`; + const derivedCode = generateDerivedClass(derivedClassName, baseClassName, discriminatorProperty, constValue, variant, knownTypes, nestedClasses, enumOutput); + nestedClasses.set(derivedClassName, derivedCode); + } + + return lines.join("\n"); +} + +/** + * Generate a derived class for a discriminated union variant. + */ +function generateDerivedClass( + className: string, + baseClassName: string, + discriminatorProperty: string, + discriminatorValue: string, + schema: JSONSchema7, + knownTypes: Map, + nestedClasses: Map, + enumOutput: string[] +): string { + const lines: string[] = []; + const required = new Set(schema.required || []); + + lines.push(`public partial class ${className} : ${baseClassName}`); + lines.push(`{`); + lines.push(` [JsonIgnore]`); + lines.push(` public override string ${toPascalCase(discriminatorProperty)} => "${discriminatorValue}";`); + lines.push(""); + + if (schema.properties) { + for (const [propName, propSchema] of Object.entries(schema.properties)) { + if (typeof propSchema !== "object") continue; + if (propName === discriminatorProperty) continue; + + const isReq = required.has(propName); + const csharpName = toPascalCase(propName); + const csharpType = resolveSessionPropertyType(propSchema as JSONSchema7, className, csharpName, isReq, knownTypes, nestedClasses, enumOutput); + + if (!isReq) lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); + lines.push(` [JsonPropertyName("${propName}")]`); + const reqMod = isReq && !csharpType.endsWith("?") ? "required " : ""; + lines.push(` public ${reqMod}${csharpType} ${csharpName} { get; set; }`, ""); + } + } + + if (lines[lines.length - 1] === "") lines.pop(); + lines.push(`}`); + return lines.join("\n"); +} + +function generateNestedClass( + className: string, + schema: JSONSchema7, + knownTypes: Map, + nestedClasses: Map, + enumOutput: string[] +): string { + const required = new Set(schema.required || []); + const lines = [`public partial class ${className}`, `{`]; + + for (const [propName, propSchema] of Object.entries(schema.properties || {})) { + if (typeof propSchema !== "object") continue; + const prop = propSchema as JSONSchema7; + const isReq = required.has(propName); + const csharpName = toPascalCase(propName); + const csharpType = resolveSessionPropertyType(prop, className, csharpName, isReq, knownTypes, nestedClasses, enumOutput); + + if (!isReq) lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); + lines.push(` [JsonPropertyName("${propName}")]`); + const reqMod = isReq && !csharpType.endsWith("?") ? "required " : ""; + lines.push(` public ${reqMod}${csharpType} ${csharpName} { get; set; }`, ""); + } + if (lines[lines.length - 1] === "") lines.pop(); + lines.push(`}`); + return lines.join("\n"); +} + +function resolveSessionPropertyType( + propSchema: JSONSchema7, + parentClassName: string, + propName: string, + isRequired: boolean, + knownTypes: Map, + nestedClasses: Map, + enumOutput: string[] +): string { + if (propSchema.anyOf) { + const hasNull = propSchema.anyOf.some((s) => typeof s === "object" && (s as JSONSchema7).type === "null"); + const nonNull = propSchema.anyOf.filter((s) => typeof s === "object" && (s as JSONSchema7).type !== "null"); + if (nonNull.length === 1) { + return resolveSessionPropertyType(nonNull[0] as JSONSchema7, parentClassName, propName, isRequired && !hasNull, knownTypes, nestedClasses, enumOutput); + } + return hasNull || !isRequired ? "object?" : "object"; + } + if (propSchema.enum && Array.isArray(propSchema.enum)) { + const enumName = getOrCreateEnum(parentClassName, propName, propSchema.enum as string[], enumOutput); + return isRequired ? enumName : `${enumName}?`; + } + if (propSchema.type === "object" && propSchema.properties) { + const nestedClassName = `${parentClassName}${propName}`; + nestedClasses.set(nestedClassName, generateNestedClass(nestedClassName, propSchema, knownTypes, nestedClasses, enumOutput)); + return isRequired ? nestedClassName : `${nestedClassName}?`; + } + if (propSchema.type === "array" && propSchema.items) { + const items = propSchema.items as JSONSchema7; + // Array of discriminated union (anyOf with shared discriminator) + if (items.anyOf && Array.isArray(items.anyOf)) { + const variants = items.anyOf.filter((v): v is JSONSchema7 => typeof v === "object"); + const discriminatorInfo = findDiscriminator(variants); + if (discriminatorInfo) { + const baseClassName = `${parentClassName}${propName}Item`; + const polymorphicCode = generatePolymorphicClasses(baseClassName, discriminatorInfo.property, variants, knownTypes, nestedClasses, enumOutput); + nestedClasses.set(baseClassName, polymorphicCode); + return isRequired ? `${baseClassName}[]` : `${baseClassName}[]?`; + } + } + if (items.type === "object" && items.properties) { + const itemClassName = `${parentClassName}${propName}Item`; + nestedClasses.set(itemClassName, generateNestedClass(itemClassName, items, knownTypes, nestedClasses, enumOutput)); + return isRequired ? `${itemClassName}[]` : `${itemClassName}[]?`; + } + if (items.enum && Array.isArray(items.enum)) { + const enumName = getOrCreateEnum(parentClassName, `${propName}Item`, items.enum as string[], enumOutput); + return isRequired ? `${enumName}[]` : `${enumName}[]?`; + } + const itemType = schemaTypeToCSharp(items, true, knownTypes); + return isRequired ? `${itemType}[]` : `${itemType}[]?`; + } + return schemaTypeToCSharp(propSchema, isRequired, knownTypes); +} + +function generateDataClass(variant: EventVariant, knownTypes: Map, nestedClasses: Map, enumOutput: string[]): string { + if (!variant.dataSchema?.properties) return `public partial class ${variant.dataClassName} { }`; + + const required = new Set(variant.dataSchema.required || []); + const lines = [`public partial class ${variant.dataClassName}`, `{`]; + + for (const [propName, propSchema] of Object.entries(variant.dataSchema.properties)) { + if (typeof propSchema !== "object") continue; + const isReq = required.has(propName); + const csharpName = toPascalCase(propName); + const csharpType = resolveSessionPropertyType(propSchema as JSONSchema7, variant.dataClassName, csharpName, isReq, knownTypes, nestedClasses, enumOutput); + + if (!isReq) lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`); + lines.push(` [JsonPropertyName("${propName}")]`); + const reqMod = isReq && !csharpType.endsWith("?") ? "required " : ""; + lines.push(` public ${reqMod}${csharpType} ${csharpName} { get; set; }`, ""); + } + if (lines[lines.length - 1] === "") lines.pop(); + lines.push(`}`); + return lines.join("\n"); +} + +function generateSessionEventsCode(schema: JSONSchema7): string { + generatedEnums.clear(); + const variants = extractEventVariants(schema); + const knownTypes = new Map(); + const nestedClasses = new Map(); + const enumOutput: string[] = []; + + const lines: string[] = []; + lines.push(`${COPYRIGHT} + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GitHub.Copilot.SDK; +`); + + // Base class with XML doc + lines.push(`/// `); + lines.push(`/// Base class for all session events with polymorphic JSON serialization.`); + lines.push(`/// `); + lines.push(`[JsonPolymorphic(`, ` TypeDiscriminatorPropertyName = "type",`, ` UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)]`); + for (const variant of [...variants].sort((a, b) => a.typeName.localeCompare(b.typeName))) { + lines.push(`[JsonDerivedType(typeof(${variant.className}), "${variant.typeName}")]`); + } + lines.push(`public abstract partial class SessionEvent`, `{`, ` [JsonPropertyName("id")]`, ` public Guid Id { get; set; }`, ""); + lines.push(` [JsonPropertyName("timestamp")]`, ` public DateTimeOffset Timestamp { get; set; }`, ""); + lines.push(` [JsonPropertyName("parentId")]`, ` public Guid? ParentId { get; set; }`, ""); + lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, ` [JsonPropertyName("ephemeral")]`, ` public bool? Ephemeral { get; set; }`, ""); + lines.push(` /// `, ` /// The event type discriminator.`, ` /// `); + lines.push(` [JsonIgnore]`, ` public abstract string Type { get; }`, ""); + lines.push(` public static SessionEvent FromJson(string json) =>`, ` JsonSerializer.Deserialize(json, SessionEventsJsonContext.Default.SessionEvent)!;`, ""); + lines.push(` public string ToJson() =>`, ` JsonSerializer.Serialize(this, SessionEventsJsonContext.Default.SessionEvent);`, `}`, ""); + + // Event classes with XML docs + for (const variant of variants) { + lines.push(`/// `, `/// Event: ${variant.typeName}`, `/// `); + lines.push(`public partial class ${variant.className} : SessionEvent`, `{`, ` [JsonIgnore]`, ` public override string Type => "${variant.typeName}";`, ""); + lines.push(` [JsonPropertyName("data")]`, ` public required ${variant.dataClassName} Data { get; set; }`, `}`, ""); + } + + // Data classes + for (const variant of variants) { + lines.push(generateDataClass(variant, knownTypes, nestedClasses, enumOutput), ""); + } + + // Nested classes + for (const [, code] of nestedClasses) lines.push(code, ""); + + // Enums + for (const code of enumOutput) lines.push(code); + + // JsonSerializerContext + const types = ["SessionEvent", ...variants.flatMap((v) => [v.className, v.dataClassName]), ...nestedClasses.keys()].sort(); + lines.push(`[JsonSourceGenerationOptions(`, ` JsonSerializerDefaults.Web,`, ` AllowOutOfOrderMetadataProperties = true,`, ` NumberHandling = JsonNumberHandling.AllowReadingFromString,`, ` DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]`); + for (const t of types) lines.push(`[JsonSerializable(typeof(${t}))]`); + lines.push(`internal partial class SessionEventsJsonContext : JsonSerializerContext;`); + + return lines.join("\n"); +} + +export async function generateSessionEvents(schemaPath?: string): Promise { + console.log("C#: generating session-events..."); + const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as JSONSchema7; + const code = generateSessionEventsCode(schema); + const outPath = await writeGeneratedFile("dotnet/src/Generated/SessionEvents.cs", code); + console.log(` ✓ ${outPath}`); + await formatCSharpFile(outPath); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// RPC TYPES +// ══════════════════════════════════════════════════════════════════════════════ + +let emittedRpcClasses = new Set(); +let rpcKnownTypes = new Map(); +let rpcEnumOutput: string[] = []; + +function singularPascal(s: string): string { + const p = toPascalCase(s); + return p.endsWith("s") ? p.slice(0, -1) : p; +} + +function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassName: string, propName: string, classes: string[]): string { + // Handle enums (string unions like "interactive" | "plan" | "autopilot") + if (schema.enum && Array.isArray(schema.enum)) { + const enumName = getOrCreateEnum(parentClassName, propName, schema.enum as string[], rpcEnumOutput); + return isRequired ? enumName : `${enumName}?`; + } + if (schema.type === "object" && schema.properties) { + const className = `${parentClassName}${propName}`; + classes.push(emitRpcClass(className, schema, "public", classes)); + return isRequired ? className : `${className}?`; + } + if (schema.type === "array" && schema.items) { + const items = schema.items as JSONSchema7; + if (items.type === "object" && items.properties) { + const itemClass = singularPascal(propName); + if (!emittedRpcClasses.has(itemClass)) classes.push(emitRpcClass(itemClass, items, "public", classes)); + return isRequired ? `List<${itemClass}>` : `List<${itemClass}>?`; + } + const itemType = schemaTypeToCSharp(items, true, rpcKnownTypes); + return isRequired ? `List<${itemType}>` : `List<${itemType}>?`; + } + if (schema.type === "object" && schema.additionalProperties && typeof schema.additionalProperties === "object") { + const vs = schema.additionalProperties as JSONSchema7; + if (vs.type === "object" && vs.properties) { + const valClass = `${parentClassName}${propName}Value`; + classes.push(emitRpcClass(valClass, vs, "public", classes)); + return isRequired ? `Dictionary` : `Dictionary?`; + } + const valueType = schemaTypeToCSharp(vs, true, rpcKnownTypes); + return isRequired ? `Dictionary` : `Dictionary?`; + } + return schemaTypeToCSharp(schema, isRequired, rpcKnownTypes); +} + +function emitRpcClass(className: string, schema: JSONSchema7, visibility: "public" | "internal", extraClasses: string[]): string { + if (emittedRpcClasses.has(className)) return ""; + emittedRpcClasses.add(className); + + const requiredSet = new Set(schema.required || []); + const lines: string[] = []; + if (schema.description) lines.push(`/// ${schema.description}`); + lines.push(`${visibility} class ${className}`, `{`); + + const props = Object.entries(schema.properties || {}); + for (let i = 0; i < props.length; i++) { + const [propName, propSchema] = props[i]; + if (typeof propSchema !== "object") continue; + const prop = propSchema as JSONSchema7; + const isReq = requiredSet.has(propName); + const csharpName = toPascalCase(propName); + const csharpType = resolveRpcType(prop, isReq, className, csharpName, extraClasses); + + if (prop.description && visibility === "public") lines.push(` /// ${prop.description}`); + lines.push(` [JsonPropertyName("${propName}")]`); + + let defaultVal = ""; + if (isReq && !csharpType.endsWith("?")) { + if (csharpType === "string") defaultVal = " = string.Empty;"; + else if (csharpType.startsWith("List<") || csharpType.startsWith("Dictionary<") || emittedRpcClasses.has(csharpType)) defaultVal = " = new();"; + } + lines.push(` public ${csharpType} ${csharpName} { get; set; }${defaultVal}`); + if (i < props.length - 1) lines.push(""); + } + lines.push(`}`); + return lines.join("\n"); +} + +/** + * Emit ServerRpc as an instance class (like SessionRpc but without sessionId). + */ +function emitServerRpcClasses(node: Record, classes: string[]): string[] { + const result: string[] = []; + + // Find top-level groups (e.g. "models", "tools", "account") + const groups = Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v)); + // Find top-level methods (e.g. "ping") + const topLevelMethods = Object.entries(node).filter(([, v]) => isRpcMethod(v)); + + // ServerRpc class + const srLines: string[] = []; + srLines.push(`/// Typed server-scoped RPC methods (no session required).`); + srLines.push(`public class ServerRpc`); + srLines.push(`{`); + srLines.push(` private readonly JsonRpc _rpc;`); + srLines.push(""); + srLines.push(` internal ServerRpc(JsonRpc rpc)`); + srLines.push(` {`); + srLines.push(` _rpc = rpc;`); + for (const [groupName] of groups) { + srLines.push(` ${toPascalCase(groupName)} = new ${toPascalCase(groupName)}Api(rpc);`); + } + srLines.push(` }`); + + // Top-level methods (like ping) + for (const [key, value] of topLevelMethods) { + if (!isRpcMethod(value)) continue; + emitServerInstanceMethod(key, value, srLines, classes, " "); + } + + // Group properties + for (const [groupName] of groups) { + srLines.push(""); + srLines.push(` /// ${toPascalCase(groupName)} APIs.`); + srLines.push(` public ${toPascalCase(groupName)}Api ${toPascalCase(groupName)} { get; }`); + } + + srLines.push(`}`); + result.push(srLines.join("\n")); + + // Per-group API classes + for (const [groupName, groupNode] of groups) { + result.push(emitServerApiClass(`${toPascalCase(groupName)}Api`, groupNode as Record, classes)); + } + + return result; +} + +function emitServerApiClass(className: string, node: Record, classes: string[]): string { + const lines: string[] = []; + lines.push(`/// Server-scoped ${className.replace("Api", "")} APIs.`); + lines.push(`public class ${className}`); + lines.push(`{`); + lines.push(` private readonly JsonRpc _rpc;`); + lines.push(""); + lines.push(` internal ${className}(JsonRpc rpc)`); + lines.push(` {`); + lines.push(` _rpc = rpc;`); + lines.push(` }`); + + for (const [key, value] of Object.entries(node)) { + if (!isRpcMethod(value)) continue; + emitServerInstanceMethod(key, value, lines, classes, " "); + } + + lines.push(`}`); + return lines.join("\n"); +} + +function emitServerInstanceMethod( + name: string, + method: { rpcMethod: string; params: JSONSchema7 | null; result: JSONSchema7 }, + lines: string[], + classes: string[], + indent: string +): void { + const methodName = toPascalCase(name); + const resultClassName = `${typeToClassName(method.rpcMethod)}Result`; + const resultClass = emitRpcClass(resultClassName, method.result, "public", classes); + if (resultClass) classes.push(resultClass); + + const paramEntries = method.params?.properties ? Object.entries(method.params.properties) : []; + const requiredSet = new Set(method.params?.required || []); + + let requestClassName: string | null = null; + if (paramEntries.length > 0) { + requestClassName = `${methodName}Request`; + const reqClass = emitRpcClass(requestClassName, method.params!, "internal", classes); + if (reqClass) classes.push(reqClass); + } + + lines.push(""); + lines.push(`${indent}/// Calls "${method.rpcMethod}".`); + + const sigParams: string[] = []; + const bodyAssignments: string[] = []; + + for (const [pName, pSchema] of paramEntries) { + if (typeof pSchema !== "object") continue; + const isReq = requiredSet.has(pName); + const csType = schemaTypeToCSharp(pSchema as JSONSchema7, isReq, rpcKnownTypes); + sigParams.push(`${csType} ${pName}${isReq ? "" : " = null"}`); + bodyAssignments.push(`${toPascalCase(pName)} = ${pName}`); + } + sigParams.push("CancellationToken cancellationToken = default"); + + lines.push(`${indent}public async Task<${resultClassName}> ${methodName}Async(${sigParams.join(", ")})`); + lines.push(`${indent}{`); + if (requestClassName && bodyAssignments.length > 0) { + lines.push(`${indent} var request = new ${requestClassName} { ${bodyAssignments.join(", ")} };`); + lines.push(`${indent} return await CopilotClient.InvokeRpcAsync<${resultClassName}>(_rpc, "${method.rpcMethod}", [request], cancellationToken);`); + } else { + lines.push(`${indent} return await CopilotClient.InvokeRpcAsync<${resultClassName}>(_rpc, "${method.rpcMethod}", [], cancellationToken);`); + } + lines.push(`${indent}}`); +} + +function emitSessionRpcClasses(node: Record, classes: string[]): string[] { + const result: string[] = []; + const groups = Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v)); + + const srLines = [`/// Typed session-scoped RPC methods.`, `public class SessionRpc`, `{`, ` private readonly JsonRpc _rpc;`, ` private readonly string _sessionId;`, ""]; + srLines.push(` internal SessionRpc(JsonRpc rpc, string sessionId)`, ` {`, ` _rpc = rpc;`, ` _sessionId = sessionId;`); + for (const [groupName] of groups) srLines.push(` ${toPascalCase(groupName)} = new ${toPascalCase(groupName)}Api(rpc, sessionId);`); + srLines.push(` }`); + for (const [groupName] of groups) srLines.push("", ` public ${toPascalCase(groupName)}Api ${toPascalCase(groupName)} { get; }`); + srLines.push(`}`); + result.push(srLines.join("\n")); + + for (const [groupName, groupNode] of groups) { + result.push(emitSessionApiClass(`${toPascalCase(groupName)}Api`, groupNode as Record, classes)); + } + return result; +} + +function emitSessionApiClass(className: string, node: Record, classes: string[]): string { + const lines = [`public class ${className}`, `{`, ` private readonly JsonRpc _rpc;`, ` private readonly string _sessionId;`, ""]; + lines.push(` internal ${className}(JsonRpc rpc, string sessionId)`, ` {`, ` _rpc = rpc;`, ` _sessionId = sessionId;`, ` }`); + + for (const [key, value] of Object.entries(node)) { + if (!isRpcMethod(value)) continue; + const method = value; + const methodName = toPascalCase(key); + const resultClassName = `${typeToClassName(method.rpcMethod)}Result`; + const resultClass = emitRpcClass(resultClassName, method.result, "public", classes); + if (resultClass) classes.push(resultClass); + + const paramEntries = (method.params?.properties ? Object.entries(method.params.properties) : []).filter(([k]) => k !== "sessionId"); + const requiredSet = new Set(method.params?.required || []); + + const requestClassName = `${methodName}Request`; + if (method.params) { + const reqClass = emitRpcClass(requestClassName, method.params, "internal", classes); + if (reqClass) classes.push(reqClass); + } + + lines.push("", ` /// Calls "${method.rpcMethod}".`); + const sigParams: string[] = []; + const bodyAssignments = [`SessionId = _sessionId`]; + + for (const [pName, pSchema] of paramEntries) { + if (typeof pSchema !== "object") continue; + const csType = resolveRpcType(pSchema as JSONSchema7, requiredSet.has(pName), requestClassName, toPascalCase(pName), classes); + sigParams.push(`${csType} ${pName}`); + bodyAssignments.push(`${toPascalCase(pName)} = ${pName}`); + } + sigParams.push("CancellationToken cancellationToken = default"); + + lines.push(` public async Task<${resultClassName}> ${methodName}Async(${sigParams.join(", ")})`); + lines.push(` {`, ` var request = new ${requestClassName} { ${bodyAssignments.join(", ")} };`); + lines.push(` return await CopilotClient.InvokeRpcAsync<${resultClassName}>(_rpc, "${method.rpcMethod}", [request], cancellationToken);`, ` }`); + } + lines.push(`}`); + return lines.join("\n"); +} + +function generateRpcCode(schema: ApiSchema): string { + emittedRpcClasses.clear(); + rpcKnownTypes.clear(); + rpcEnumOutput = []; + generatedEnums.clear(); // Clear shared enum deduplication map + const classes: string[] = []; + + let serverRpcParts: string[] = []; + if (schema.server) serverRpcParts = emitServerRpcClasses(schema.server, classes); + + let sessionRpcParts: string[] = []; + if (schema.session) sessionRpcParts = emitSessionRpcClasses(schema.session, classes); + + const lines: string[] = []; + lines.push(`${COPYRIGHT} + +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: api.schema.json + +using System.Text.Json; +using System.Text.Json.Serialization; +using StreamJsonRpc; + +namespace GitHub.Copilot.SDK.Rpc; +`); + + for (const cls of classes) if (cls) lines.push(cls, ""); + for (const enumCode of rpcEnumOutput) lines.push(enumCode, ""); + for (const part of serverRpcParts) lines.push(part, ""); + for (const part of sessionRpcParts) lines.push(part, ""); + + // Add JsonSerializerContext for AOT/trimming support + const typeNames = [...emittedRpcClasses].sort(); + if (typeNames.length > 0) { + lines.push(`[JsonSourceGenerationOptions(`); + lines.push(` JsonSerializerDefaults.Web,`); + lines.push(` AllowOutOfOrderMetadataProperties = true,`); + lines.push(` DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]`); + for (const t of typeNames) lines.push(`[JsonSerializable(typeof(${t}))]`); + lines.push(`internal partial class RpcJsonContext : JsonSerializerContext;`); + } + + return lines.join("\n"); +} + +export async function generateRpc(schemaPath?: string): Promise { + console.log("C#: generating RPC types..."); + const resolvedPath = schemaPath ?? (await getApiSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as ApiSchema; + const code = generateRpcCode(schema); + const outPath = await writeGeneratedFile("dotnet/src/Generated/Rpc.cs", code); + console.log(` ✓ ${outPath}`); + await formatCSharpFile(outPath); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// MAIN +// ══════════════════════════════════════════════════════════════════════════════ + +async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Promise { + await generateSessionEvents(sessionSchemaPath); + try { + await generateRpc(apiSchemaPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT" && !apiSchemaPath) { + console.log("C#: skipping RPC (api.schema.json not found)"); + } else { + throw err; + } + } +} + +const sessionArg = process.argv[2] || undefined; +const apiArg = process.argv[3] || undefined; +generate(sessionArg, apiArg).catch((err) => { + console.error("C# generation failed:", err); + process.exit(1); +}); diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts new file mode 100644 index 000000000..411d1c90f --- /dev/null +++ b/scripts/codegen/go.ts @@ -0,0 +1,312 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Go code generator for session-events and RPC types. + */ + +import { execFile } from "child_process"; +import fs from "fs/promises"; +import { promisify } from "util"; +import type { JSONSchema7 } from "json-schema"; +import { FetchingJSONSchemaStore, InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { + getSessionEventsSchemaPath, + getApiSchemaPath, + postProcessSchema, + writeGeneratedFile, + isRpcMethod, + type ApiSchema, + type RpcMethod, +} from "./utils.js"; + +const execFileAsync = promisify(execFile); + +// ── Utilities ─────────────────────────────────────────────────────────────── + +// Go initialisms that should be all-caps +const goInitialisms = new Set(["id", "url", "api", "http", "https", "json", "xml", "html", "css", "sql", "ssh", "tcp", "udp", "ip", "rpc"]); + +function toPascalCase(s: string): string { + return s + .split(/[._]/) + .map((w) => goInitialisms.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)) + .join(""); +} + +function toGoFieldName(jsonName: string): string { + // Handle camelCase field names like "modelId" -> "ModelID" + return jsonName + .replace(/([a-z])([A-Z])/g, "$1_$2") + .split("_") + .map((w) => goInitialisms.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(""); +} + +async function formatGoFile(filePath: string): Promise { + try { + await execFileAsync("go", ["fmt", filePath]); + console.log(` ✓ Formatted with go fmt`); + } catch { + // go fmt not available, skip + } +} + +function collectRpcMethods(node: Record): RpcMethod[] { + const results: RpcMethod[] = []; + for (const value of Object.values(node)) { + if (isRpcMethod(value)) { + results.push(value); + } else if (typeof value === "object" && value !== null) { + results.push(...collectRpcMethods(value as Record)); + } + } + return results; +} + +// ── Session Events ────────────────────────────────────────────────────────── + +async function generateSessionEvents(schemaPath?: string): Promise { + console.log("Go: generating session-events..."); + + const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as JSONSchema7; + const resolvedSchema = (schema.definitions?.SessionEvent as JSONSchema7) || schema; + const processed = postProcessSchema(resolvedSchema); + + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + await schemaInput.addSource({ name: "SessionEvent", schema: JSON.stringify(processed) }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "go", + rendererOptions: { package: "copilot" }, + }); + + const banner = `// AUTO-GENERATED FILE - DO NOT EDIT +// Generated from: session-events.schema.json + +`; + + const outPath = await writeGeneratedFile("go/generated_session_events.go", banner + result.lines.join("\n")); + console.log(` ✓ ${outPath}`); + + await formatGoFile(outPath); +} + +// ── RPC Types ─────────────────────────────────────────────────────────────── + +async function generateRpc(schemaPath?: string): Promise { + console.log("Go: generating RPC types..."); + + const resolvedPath = schemaPath ?? (await getApiSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as ApiSchema; + + const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; + + // Build a combined schema for quicktype - prefix types to avoid conflicts + const combinedSchema: JSONSchema7 = { + $schema: "http://json-schema.org/draft-07/schema#", + definitions: {}, + }; + + for (const method of allMethods) { + const baseName = toPascalCase(method.rpcMethod); + if (method.result) { + combinedSchema.definitions![baseName + "Result"] = method.result; + } + if (method.params?.properties && Object.keys(method.params.properties).length > 0) { + // For session methods, filter out sessionId from params type + if (method.rpcMethod.startsWith("session.")) { + const filtered: JSONSchema7 = { + ...method.params, + properties: Object.fromEntries( + Object.entries(method.params.properties).filter(([k]) => k !== "sessionId") + ), + required: method.params.required?.filter((r) => r !== "sessionId"), + }; + if (Object.keys(filtered.properties!).length > 0) { + combinedSchema.definitions![baseName + "Params"] = filtered; + } + } else { + combinedSchema.definitions![baseName + "Params"] = method.params; + } + } + } + + // Generate types via quicktype + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + for (const [name, def] of Object.entries(combinedSchema.definitions!)) { + await schemaInput.addSource({ name, schema: JSON.stringify(def) }); + } + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const qtResult = await quicktype({ + inputData, + lang: "go", + rendererOptions: { package: "copilot", "just-types": "true" }, + }); + + // Build method wrappers + const lines: string[] = []; + lines.push(`// AUTO-GENERATED FILE - DO NOT EDIT`); + lines.push(`// Generated from: api.schema.json`); + lines.push(``); + lines.push(`package rpc`); + lines.push(``); + lines.push(`import (`); + lines.push(` "context"`); + lines.push(` "encoding/json"`); + lines.push(``); + lines.push(` "github.com/github/copilot-sdk/go/internal/jsonrpc2"`); + lines.push(`)`); + lines.push(``); + + // Add quicktype-generated types (skip package line) + const qtLines = qtResult.lines.filter((l) => !l.startsWith("package ")); + lines.push(...qtLines); + lines.push(``); + + // Emit ServerRpc + if (schema.server) { + emitRpcWrapper(lines, schema.server, false); + } + + // Emit SessionRpc + if (schema.session) { + emitRpcWrapper(lines, schema.session, true); + } + + const outPath = await writeGeneratedFile("go/rpc/generated_rpc.go", lines.join("\n")); + console.log(` ✓ ${outPath}`); + + await formatGoFile(outPath); +} + +function emitRpcWrapper(lines: string[], node: Record, isSession: boolean): void { + const groups = Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v)); + const topLevelMethods = Object.entries(node).filter(([, v]) => isRpcMethod(v)); + + const wrapperName = isSession ? "SessionRpc" : "ServerRpc"; + const apiSuffix = "RpcApi"; + + // Emit API structs for groups + for (const [groupName, groupNode] of groups) { + const apiName = toPascalCase(groupName) + apiSuffix; + const fields = isSession ? "client *jsonrpc2.Client; sessionID string" : "client *jsonrpc2.Client"; + lines.push(`type ${apiName} struct { ${fields} }`); + lines.push(``); + for (const [key, value] of Object.entries(groupNode as Record)) { + if (!isRpcMethod(value)) continue; + emitMethod(lines, apiName, key, value, isSession); + } + } + + // Emit wrapper struct + lines.push(`// ${wrapperName} provides typed ${isSession ? "session" : "server"}-scoped RPC methods.`); + lines.push(`type ${wrapperName} struct {`); + lines.push(` client *jsonrpc2.Client`); + if (isSession) lines.push(` sessionID string`); + for (const [groupName] of groups) { + lines.push(` ${toPascalCase(groupName)} *${toPascalCase(groupName)}${apiSuffix}`); + } + lines.push(`}`); + lines.push(``); + + // Top-level methods (server only) + for (const [key, value] of topLevelMethods) { + if (!isRpcMethod(value)) continue; + emitMethod(lines, wrapperName, key, value, isSession); + } + + // Constructor + const ctorParams = isSession ? "client *jsonrpc2.Client, sessionID string" : "client *jsonrpc2.Client"; + const ctorFields = isSession ? "client: client, sessionID: sessionID," : "client: client,"; + lines.push(`func New${wrapperName}(${ctorParams}) *${wrapperName} {`); + lines.push(` return &${wrapperName}{${ctorFields}`); + for (const [groupName] of groups) { + const apiInit = isSession + ? `&${toPascalCase(groupName)}${apiSuffix}{client: client, sessionID: sessionID}` + : `&${toPascalCase(groupName)}${apiSuffix}{client: client}`; + lines.push(` ${toPascalCase(groupName)}: ${apiInit},`); + } + lines.push(` }`); + lines.push(`}`); + lines.push(``); +} + +function emitMethod(lines: string[], receiver: string, name: string, method: RpcMethod, isSession: boolean): void { + const methodName = toPascalCase(name); + const resultType = toPascalCase(method.rpcMethod) + "Result"; + + const paramProps = method.params?.properties || {}; + const requiredParams = new Set(method.params?.required || []); + const nonSessionParams = Object.keys(paramProps).filter((k) => k !== "sessionId"); + const hasParams = isSession ? nonSessionParams.length > 0 : Object.keys(paramProps).length > 0; + const paramsType = hasParams ? toPascalCase(method.rpcMethod) + "Params" : ""; + + const sig = hasParams + ? `func (a *${receiver}) ${methodName}(ctx context.Context, params *${paramsType}) (*${resultType}, error)` + : `func (a *${receiver}) ${methodName}(ctx context.Context) (*${resultType}, error)`; + + lines.push(sig + ` {`); + + if (isSession) { + lines.push(` req := map[string]interface{}{"sessionId": a.sessionID}`); + if (hasParams) { + lines.push(` if params != nil {`); + for (const pName of nonSessionParams) { + const goField = toGoFieldName(pName); + const isOptional = !requiredParams.has(pName); + if (isOptional) { + // Optional fields are pointers - only add when non-nil and dereference + lines.push(` if params.${goField} != nil {`); + lines.push(` req["${pName}"] = *params.${goField}`); + lines.push(` }`); + } else { + lines.push(` req["${pName}"] = params.${goField}`); + } + } + lines.push(` }`); + } + lines.push(` raw, err := a.client.Request("${method.rpcMethod}", req)`); + } else { + const arg = hasParams ? "params" : "map[string]interface{}{}"; + lines.push(` raw, err := a.client.Request("${method.rpcMethod}", ${arg})`); + } + + lines.push(` if err != nil { return nil, err }`); + lines.push(` var result ${resultType}`); + lines.push(` if err := json.Unmarshal(raw, &result); err != nil { return nil, err }`); + lines.push(` return &result, nil`); + lines.push(`}`); + lines.push(``); +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Promise { + await generateSessionEvents(sessionSchemaPath); + try { + await generateRpc(apiSchemaPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT" && !apiSchemaPath) { + console.log("Go: skipping RPC (api.schema.json not found)"); + } else { + throw err; + } + } +} + +const sessionArg = process.argv[2] || undefined; +const apiArg = process.argv[3] || undefined; +generate(sessionArg, apiArg).catch((err) => { + console.error("Go generation failed:", err); + process.exit(1); +}); diff --git a/scripts/codegen/package-lock.json b/scripts/codegen/package-lock.json new file mode 100644 index 000000000..a02811c67 --- /dev/null +++ b/scripts/codegen/package-lock.json @@ -0,0 +1,1030 @@ +{ + "name": "codegen", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codegen", + "dependencies": { + "json-schema": "^0.4.0", + "json-schema-to-typescript": "^15.0.4", + "quicktype-core": "^23.2.6", + "tsx": "^4.20.6" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@glideapps/ts-necessities": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@glideapps/ts-necessities/-/ts-necessities-2.2.3.tgz", + "integrity": "sha512-gXi0awOZLHk3TbW55GZLCPP6O+y/b5X1pBXKBVckFONSwF1z1E5ND2BGJsghQFah+pW7pkkyFb2VhUQI2qhL5w==", + "license": "MIT" + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/browser-or-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-3.0.0.tgz", + "integrity": "sha512-iczIdVJzGEYhP5DqQxYM9Hh7Ztpqqi+CXZpSmX8ALFs9ecXkQIeqRyM6TfxEfMVpwhl3dSuDvxdzzo9sUOIVBQ==", + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/collection-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/collection-utils/-/collection-utils-1.0.1.tgz", + "integrity": "sha512-LA2YTIlR7biSpXkKYwwuzGjwL5rjWEZVOSnvdUc7gObvWe4WkjxOpfrdhoP7Hs09YWDVfg0Mal9BpAqLfVEzQg==", + "license": "Apache-2.0" + }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/quicktype-core": { + "version": "23.2.6", + "resolved": "https://registry.npmjs.org/quicktype-core/-/quicktype-core-23.2.6.tgz", + "integrity": "sha512-asfeSv7BKBNVb9WiYhFRBvBZHcRutPRBwJMxW0pefluK4kkKu4lv0IvZBwFKvw2XygLcL1Rl90zxWDHYgkwCmA==", + "license": "Apache-2.0", + "dependencies": { + "@glideapps/ts-necessities": "2.2.3", + "browser-or-node": "^3.0.0", + "collection-utils": "^1.0.1", + "cross-fetch": "^4.0.0", + "is-url": "^1.2.4", + "js-base64": "^3.7.7", + "lodash": "^4.17.21", + "pako": "^1.0.6", + "pluralize": "^8.0.0", + "readable-stream": "4.5.2", + "unicode-properties": "^1.4.1", + "urijs": "^1.19.1", + "wordwrap": "^1.0.0", + "yaml": "^2.4.1" + } + }, + "node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/scripts/codegen/package.json b/scripts/codegen/package.json new file mode 100644 index 000000000..a2df5dded --- /dev/null +++ b/scripts/codegen/package.json @@ -0,0 +1,18 @@ +{ + "name": "codegen", + "private": true, + "type": "module", + "scripts": { + "generate": "tsx typescript.ts && tsx csharp.ts && tsx python.ts && tsx go.ts", + "generate:ts": "tsx typescript.ts", + "generate:csharp": "tsx csharp.ts", + "generate:python": "tsx python.ts", + "generate:go": "tsx go.ts" + }, + "dependencies": { + "json-schema": "^0.4.0", + "json-schema-to-typescript": "^15.0.4", + "quicktype-core": "^23.2.6", + "tsx": "^4.20.6" + } +} diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts new file mode 100644 index 000000000..aa688782b --- /dev/null +++ b/scripts/codegen/python.ts @@ -0,0 +1,305 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Python code generator for session-events and RPC types. + */ + +import fs from "fs/promises"; +import type { JSONSchema7 } from "json-schema"; +import { FetchingJSONSchemaStore, InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { + getSessionEventsSchemaPath, + getApiSchemaPath, + postProcessSchema, + writeGeneratedFile, + isRpcMethod, + type ApiSchema, + type RpcMethod, +} from "./utils.js"; + +// ── Utilities ─────────────────────────────────────────────────────────────── + +function toSnakeCase(s: string): string { + return s + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/[._]/g, "_") + .toLowerCase(); +} + +function toPascalCase(s: string): string { + return s + .split(/[._]/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(""); +} + +function collectRpcMethods(node: Record): RpcMethod[] { + const results: RpcMethod[] = []; + for (const value of Object.values(node)) { + if (isRpcMethod(value)) { + results.push(value); + } else if (typeof value === "object" && value !== null) { + results.push(...collectRpcMethods(value as Record)); + } + } + return results; +} + +// ── Session Events ────────────────────────────────────────────────────────── + +async function generateSessionEvents(schemaPath?: string): Promise { + console.log("Python: generating session-events..."); + + const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as JSONSchema7; + const resolvedSchema = (schema.definitions?.SessionEvent as JSONSchema7) || schema; + const processed = postProcessSchema(resolvedSchema); + + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + await schemaInput.addSource({ name: "SessionEvent", schema: JSON.stringify(processed) }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "python", + rendererOptions: { "python-version": "3.7" }, + }); + + let code = result.lines.join("\n"); + + // Fix dataclass field ordering (Any fields need defaults) + code = code.replace(/: Any$/gm, ": Any = None"); + // Fix bare except: to use Exception (required by ruff/pylint) + code = code.replace(/except:/g, "except Exception:"); + + // Add UNKNOWN enum value for forward compatibility + code = code.replace( + /^(class SessionEventType\(Enum\):.*?)(^\s*\n@dataclass)/ms, + `$1 # UNKNOWN is used for forward compatibility + UNKNOWN = "unknown" + + @classmethod + def _missing_(cls, value: object) -> "SessionEventType": + """Handle unknown event types gracefully for forward compatibility.""" + return cls.UNKNOWN + +$2` + ); + + const banner = `""" +AUTO-GENERATED FILE - DO NOT EDIT +Generated from: session-events.schema.json +""" + +`; + + const outPath = await writeGeneratedFile("python/copilot/generated/session_events.py", banner + code); + console.log(` ✓ ${outPath}`); +} + +// ── RPC Types ─────────────────────────────────────────────────────────────── + +async function generateRpc(schemaPath?: string): Promise { + console.log("Python: generating RPC types..."); + + const resolvedPath = schemaPath ?? (await getApiSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as ApiSchema; + + const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; + + // Build a combined schema for quicktype + const combinedSchema: JSONSchema7 = { + $schema: "http://json-schema.org/draft-07/schema#", + definitions: {}, + }; + + for (const method of allMethods) { + const baseName = toPascalCase(method.rpcMethod); + if (method.result) { + combinedSchema.definitions![baseName + "Result"] = method.result; + } + if (method.params?.properties && Object.keys(method.params.properties).length > 0) { + if (method.rpcMethod.startsWith("session.")) { + const filtered: JSONSchema7 = { + ...method.params, + properties: Object.fromEntries( + Object.entries(method.params.properties).filter(([k]) => k !== "sessionId") + ), + required: method.params.required?.filter((r) => r !== "sessionId"), + }; + if (Object.keys(filtered.properties!).length > 0) { + combinedSchema.definitions![baseName + "Params"] = filtered; + } + } else { + combinedSchema.definitions![baseName + "Params"] = method.params; + } + } + } + + // Generate types via quicktype + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + for (const [name, def] of Object.entries(combinedSchema.definitions!)) { + await schemaInput.addSource({ name, schema: JSON.stringify(def) }); + } + + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const qtResult = await quicktype({ + inputData, + lang: "python", + rendererOptions: { "python-version": "3.7" }, + }); + + let typesCode = qtResult.lines.join("\n"); + // Fix dataclass field ordering + typesCode = typesCode.replace(/: Any$/gm, ": Any = None"); + // Fix bare except: to use Exception (required by ruff/pylint) + typesCode = typesCode.replace(/except:/g, "except Exception:"); + // Remove unnecessary pass when class has methods (quicktype generates pass for empty schemas) + typesCode = typesCode.replace(/^(\s*)pass\n\n(\s*@staticmethod)/gm, "$2"); + + const lines: string[] = []; + lines.push(`""" +AUTO-GENERATED FILE - DO NOT EDIT +Generated from: api.schema.json +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..jsonrpc import JsonRpcClient + +`); + lines.push(typesCode); + lines.push(``); + + // Emit RPC wrapper classes + if (schema.server) { + emitRpcWrapper(lines, schema.server, false); + } + if (schema.session) { + emitRpcWrapper(lines, schema.session, true); + } + + const outPath = await writeGeneratedFile("python/copilot/generated/rpc.py", lines.join("\n")); + console.log(` ✓ ${outPath}`); +} + +function emitRpcWrapper(lines: string[], node: Record, isSession: boolean): void { + const groups = Object.entries(node).filter(([, v]) => typeof v === "object" && v !== null && !isRpcMethod(v)); + const topLevelMethods = Object.entries(node).filter(([, v]) => isRpcMethod(v)); + + const wrapperName = isSession ? "SessionRpc" : "ServerRpc"; + + // Emit API classes for groups + for (const [groupName, groupNode] of groups) { + const apiName = toPascalCase(groupName) + "Api"; + if (isSession) { + lines.push(`class ${apiName}:`); + lines.push(` def __init__(self, client: "JsonRpcClient", session_id: str):`); + lines.push(` self._client = client`); + lines.push(` self._session_id = session_id`); + } else { + lines.push(`class ${apiName}:`); + lines.push(` def __init__(self, client: "JsonRpcClient"):`); + lines.push(` self._client = client`); + } + lines.push(``); + for (const [key, value] of Object.entries(groupNode as Record)) { + if (!isRpcMethod(value)) continue; + emitMethod(lines, key, value, isSession); + } + lines.push(``); + } + + // Emit wrapper class + if (isSession) { + lines.push(`class ${wrapperName}:`); + lines.push(` """Typed session-scoped RPC methods."""`); + lines.push(` def __init__(self, client: "JsonRpcClient", session_id: str):`); + lines.push(` self._client = client`); + lines.push(` self._session_id = session_id`); + for (const [groupName] of groups) { + lines.push(` self.${toSnakeCase(groupName)} = ${toPascalCase(groupName)}Api(client, session_id)`); + } + } else { + lines.push(`class ${wrapperName}:`); + lines.push(` """Typed server-scoped RPC methods."""`); + lines.push(` def __init__(self, client: "JsonRpcClient"):`); + lines.push(` self._client = client`); + for (const [groupName] of groups) { + lines.push(` self.${toSnakeCase(groupName)} = ${toPascalCase(groupName)}Api(client)`); + } + } + lines.push(``); + + // Top-level methods + for (const [key, value] of topLevelMethods) { + if (!isRpcMethod(value)) continue; + emitMethod(lines, key, value, isSession); + } + lines.push(``); +} + +function emitMethod(lines: string[], name: string, method: RpcMethod, isSession: boolean): void { + const methodName = toSnakeCase(name); + const resultType = toPascalCase(method.rpcMethod) + "Result"; + + const paramProps = method.params?.properties || {}; + const nonSessionParams = Object.keys(paramProps).filter((k) => k !== "sessionId"); + const hasParams = isSession ? nonSessionParams.length > 0 : Object.keys(paramProps).length > 0; + const paramsType = toPascalCase(method.rpcMethod) + "Params"; + + // Build signature with typed params + const sig = hasParams + ? ` async def ${methodName}(self, params: ${paramsType}) -> ${resultType}:` + : ` async def ${methodName}(self) -> ${resultType}:`; + + lines.push(sig); + + // Build request body with proper serialization/deserialization + if (isSession) { + if (hasParams) { + lines.push(` params_dict = {k: v for k, v in params.to_dict().items() if v is not None}`); + lines.push(` params_dict["sessionId"] = self._session_id`); + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", params_dict))`); + } else { + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", {"sessionId": self._session_id}))`); + } + } else { + if (hasParams) { + lines.push(` params_dict = {k: v for k, v in params.to_dict().items() if v is not None}`); + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", params_dict))`); + } else { + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", {}))`); + } + } + lines.push(``); +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Promise { + await generateSessionEvents(sessionSchemaPath); + try { + await generateRpc(apiSchemaPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT" && !apiSchemaPath) { + console.log("Python: skipping RPC (api.schema.json not found)"); + } else { + throw err; + } + } +} + +const sessionArg = process.argv[2] || undefined; +const apiArg = process.argv[3] || undefined; +generate(sessionArg, apiArg).catch((err) => { + console.error("Python generation failed:", err); + process.exit(1); +}); diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts new file mode 100644 index 000000000..77c31019a --- /dev/null +++ b/scripts/codegen/typescript.ts @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * TypeScript code generator for session-events and RPC types. + */ + +import fs from "fs/promises"; +import type { JSONSchema7 } from "json-schema"; +import { compile } from "json-schema-to-typescript"; +import { + getSessionEventsSchemaPath, + getApiSchemaPath, + postProcessSchema, + writeGeneratedFile, + isRpcMethod, + type ApiSchema, + type RpcMethod, +} from "./utils.js"; + +// ── Utilities ─────────────────────────────────────────────────────────────── + +function toPascalCase(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function collectRpcMethods(node: Record): RpcMethod[] { + const results: RpcMethod[] = []; + for (const value of Object.values(node)) { + if (isRpcMethod(value)) { + results.push(value); + } else if (typeof value === "object" && value !== null) { + results.push(...collectRpcMethods(value as Record)); + } + } + return results; +} + +// ── Session Events ────────────────────────────────────────────────────────── + +async function generateSessionEvents(schemaPath?: string): Promise { + console.log("TypeScript: generating session-events..."); + + const resolvedPath = schemaPath ?? (await getSessionEventsSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as JSONSchema7; + const processed = postProcessSchema(schema); + + const ts = await compile(processed, "SessionEvent", { + bannerComment: `/** + * AUTO-GENERATED FILE - DO NOT EDIT + * Generated from: session-events.schema.json + */`, + style: { semi: true, singleQuote: false, trailingComma: "all" }, + additionalProperties: false, + }); + + const outPath = await writeGeneratedFile("nodejs/src/generated/session-events.ts", ts); + console.log(` ✓ ${outPath}`); +} + +// ── RPC Types ─────────────────────────────────────────────────────────────── + +function resultTypeName(rpcMethod: string): string { + return rpcMethod.split(".").map(toPascalCase).join("") + "Result"; +} + +function paramsTypeName(rpcMethod: string): string { + return rpcMethod.split(".").map(toPascalCase).join("") + "Params"; +} + +async function generateRpc(schemaPath?: string): Promise { + console.log("TypeScript: generating RPC types..."); + + const resolvedPath = schemaPath ?? (await getApiSchemaPath()); + const schema = JSON.parse(await fs.readFile(resolvedPath, "utf-8")) as ApiSchema; + + const lines: string[] = []; + lines.push(`/** + * AUTO-GENERATED FILE - DO NOT EDIT + * Generated from: api.schema.json + */ + +import type { MessageConnection } from "vscode-jsonrpc/node.js"; +`); + + const allMethods = [...collectRpcMethods(schema.server || {}), ...collectRpcMethods(schema.session || {})]; + + for (const method of allMethods) { + const compiled = await compile(method.result, resultTypeName(method.rpcMethod), { + bannerComment: "", + additionalProperties: false, + }); + lines.push(compiled.trim()); + lines.push(""); + + if (method.params?.properties && Object.keys(method.params.properties).length > 0) { + const paramsCompiled = await compile(method.params, paramsTypeName(method.rpcMethod), { + bannerComment: "", + additionalProperties: false, + }); + lines.push(paramsCompiled.trim()); + lines.push(""); + } + } + + // Generate factory functions + if (schema.server) { + lines.push(`/** Create typed server-scoped RPC methods (no session required). */`); + lines.push(`export function createServerRpc(connection: MessageConnection) {`); + lines.push(` return {`); + lines.push(...emitGroup(schema.server, " ", false)); + lines.push(` };`); + lines.push(`}`); + lines.push(""); + } + + if (schema.session) { + lines.push(`/** Create typed session-scoped RPC methods. */`); + lines.push(`export function createSessionRpc(connection: MessageConnection, sessionId: string) {`); + lines.push(` return {`); + lines.push(...emitGroup(schema.session, " ", true)); + lines.push(` };`); + lines.push(`}`); + lines.push(""); + } + + const outPath = await writeGeneratedFile("nodejs/src/generated/rpc.ts", lines.join("\n")); + console.log(` ✓ ${outPath}`); +} + +function emitGroup(node: Record, indent: string, isSession: boolean): string[] { + const lines: string[] = []; + for (const [key, value] of Object.entries(node)) { + if (isRpcMethod(value)) { + const { rpcMethod, params } = value; + const resultType = resultTypeName(rpcMethod); + const paramsType = paramsTypeName(rpcMethod); + + const paramEntries = params?.properties ? Object.entries(params.properties).filter(([k]) => k !== "sessionId") : []; + const hasParams = params?.properties && Object.keys(params.properties).length > 0; + const hasNonSessionParams = paramEntries.length > 0; + + const sigParams: string[] = []; + let bodyArg: string; + + if (isSession) { + if (hasNonSessionParams) { + sigParams.push(`params: Omit<${paramsType}, "sessionId">`); + bodyArg = "{ sessionId, ...params }"; + } else { + bodyArg = "{ sessionId }"; + } + } else { + if (hasParams) { + sigParams.push(`params: ${paramsType}`); + bodyArg = "params"; + } else { + bodyArg = "{}"; + } + } + + lines.push(`${indent}${key}: async (${sigParams.join(", ")}): Promise<${resultType}> =>`); + lines.push(`${indent} connection.sendRequest("${rpcMethod}", ${bodyArg}),`); + } else if (typeof value === "object" && value !== null) { + lines.push(`${indent}${key}: {`); + lines.push(...emitGroup(value as Record, indent + " ", isSession)); + lines.push(`${indent}},`); + } + } + return lines; +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +async function generate(sessionSchemaPath?: string, apiSchemaPath?: string): Promise { + await generateSessionEvents(sessionSchemaPath); + try { + await generateRpc(apiSchemaPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT" && !apiSchemaPath) { + console.log("TypeScript: skipping RPC (api.schema.json not found)"); + } else { + throw err; + } + } +} + +const sessionArg = process.argv[2] || undefined; +const apiArg = process.argv[3] || undefined; +generate(sessionArg, apiArg).catch((err) => { + console.error("TypeScript generation failed:", err); + process.exit(1); +}); diff --git a/scripts/codegen/utils.ts b/scripts/codegen/utils.ts new file mode 100644 index 000000000..88ca68de8 --- /dev/null +++ b/scripts/codegen/utils.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Shared utilities for code generation - schema loading, file I/O, schema processing. + */ + +import { execFile } from "child_process"; +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; +import { promisify } from "util"; +import type { JSONSchema7, JSONSchema7Definition } from "json-schema"; + +export const execFileAsync = promisify(execFile); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** Root of the copilot-sdk repo */ +export const REPO_ROOT = path.resolve(__dirname, "../.."); + +/** Event types to exclude from generation (internal/legacy types) */ +export const EXCLUDED_EVENT_TYPES = new Set(["session.import_legacy"]); + +// ── Schema paths ──────────────────────────────────────────────────────────── + +export async function getSessionEventsSchemaPath(): Promise { + const schemaPath = path.join( + REPO_ROOT, + "nodejs/node_modules/@github/copilot/schemas/session-events.schema.json" + ); + await fs.access(schemaPath); + return schemaPath; +} + +export async function getApiSchemaPath(cliArg?: string): Promise { + if (cliArg) return cliArg; + const schemaPath = path.join( + REPO_ROOT, + "nodejs/node_modules/@github/copilot/schemas/api.schema.json" + ); + await fs.access(schemaPath); + return schemaPath; +} + +// ── Schema processing ─────────────────────────────────────────────────────── + +/** + * Post-process JSON Schema for quicktype compatibility. + * Converts boolean const values to enum, filters excluded event types. + */ +export function postProcessSchema(schema: JSONSchema7): JSONSchema7 { + if (typeof schema !== "object" || schema === null) return schema; + + const processed: JSONSchema7 = { ...schema }; + + if ("const" in processed && typeof processed.const === "boolean") { + processed.enum = [processed.const]; + delete processed.const; + } + + if (processed.properties) { + const newProps: Record = {}; + for (const [key, value] of Object.entries(processed.properties)) { + newProps[key] = typeof value === "object" ? postProcessSchema(value as JSONSchema7) : value; + } + processed.properties = newProps; + } + + if (processed.items) { + if (typeof processed.items === "object" && !Array.isArray(processed.items)) { + processed.items = postProcessSchema(processed.items as JSONSchema7); + } else if (Array.isArray(processed.items)) { + processed.items = processed.items.map((item) => + typeof item === "object" ? postProcessSchema(item as JSONSchema7) : item + ) as JSONSchema7Definition[]; + } + } + + for (const combiner of ["anyOf", "allOf", "oneOf"] as const) { + if (processed[combiner]) { + processed[combiner] = processed[combiner]! + .filter((item) => { + if (typeof item !== "object") return true; + const typeConst = (item as JSONSchema7).properties?.type; + if (typeof typeConst === "object" && "const" in typeConst) { + return !EXCLUDED_EVENT_TYPES.has(typeConst.const as string); + } + return true; + }) + .map((item) => + typeof item === "object" ? postProcessSchema(item as JSONSchema7) : item + ) as JSONSchema7Definition[]; + } + } + + if (processed.definitions) { + const newDefs: Record = {}; + for (const [key, value] of Object.entries(processed.definitions)) { + newDefs[key] = typeof value === "object" ? postProcessSchema(value as JSONSchema7) : value; + } + processed.definitions = newDefs; + } + + if (typeof processed.additionalProperties === "object") { + processed.additionalProperties = postProcessSchema(processed.additionalProperties as JSONSchema7); + } + + return processed; +} + +// ── File output ───────────────────────────────────────────────────────────── + +export async function writeGeneratedFile(relativePath: string, content: string): Promise { + const fullPath = path.join(REPO_ROOT, relativePath); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content, "utf-8"); + return fullPath; +} + +// ── RPC schema types ──────────────────────────────────────────────────────── + +export interface RpcMethod { + rpcMethod: string; + params: JSONSchema7 | null; + result: JSONSchema7; +} + +export interface ApiSchema { + server?: Record; + session?: Record; +} + +export function isRpcMethod(node: unknown): node is RpcMethod { + return typeof node === "object" && node !== null && "rpcMethod" in node; +} diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index d1725f037..0bb201f5f 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^0.0.403", + "@github/copilot": "^0.0.411", + "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.2.0", "openai": "^6.17.0", "tsx": "^4.21.0", @@ -461,27 +462,27 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.403.tgz", - "integrity": "sha512-v5jUdtGJReLmE1rmff/LZf+50nzmYQYAaSRNtVNr9g0j0GkCd/noQExe31i1+PudvWU0ZJjltR0B8pUfDRdA9Q==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.411.tgz", + "integrity": "sha512-I3/7gw40Iu1O+kTyNPKJHNqDRyOebjsUW6wJsvSVrOpT0TNa3/lfm8xdS2XUuJWkp+PgEG/PRwF7u3DVNdP7bQ==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.403", - "@github/copilot-darwin-x64": "0.0.403", - "@github/copilot-linux-arm64": "0.0.403", - "@github/copilot-linux-x64": "0.0.403", - "@github/copilot-win32-arm64": "0.0.403", - "@github/copilot-win32-x64": "0.0.403" + "@github/copilot-darwin-arm64": "0.0.411", + "@github/copilot-darwin-x64": "0.0.411", + "@github/copilot-linux-arm64": "0.0.411", + "@github/copilot-linux-x64": "0.0.411", + "@github/copilot-win32-arm64": "0.0.411", + "@github/copilot-win32-x64": "0.0.411" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.403.tgz", - "integrity": "sha512-dOw8IleA0d1soHnbr/6wc6vZiYWNTKMgfTe/NET1nCfMzyKDt/0F0I7PT5y+DLujJknTla/ZeEmmBUmliTW4Cg==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.411.tgz", + "integrity": "sha512-dtr+iHxTS4f8HlV2JT9Fp0FFoxuiPWCnU3XGmrHK+rY6bX5okPC2daU5idvs77WKUGcH8yHTZtfbKYUiMxKosw==", "cpu": [ "arm64" ], @@ -496,9 +497,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.403.tgz", - "integrity": "sha512-aK2jSNWgY8eiZ+TmrvGhssMCPDTKArc0ip6Ul5OaslpytKks8hyXoRbxGD0N9sKioSUSbvKUf+1AqavbDpJO+w==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.411.tgz", + "integrity": "sha512-zhdbQCbPi1L4iHClackSLx8POfklA+NX9RQLuS48HlKi/0KI/JlaDA/bdbIeMR79wjif5t9gnc/m+RTVmHlRtA==", "cpu": [ "x64" ], @@ -513,9 +514,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.403.tgz", - "integrity": "sha512-KhoR2iR70O6vCkzf0h8/K+p82qAgOvMTgAPm9bVEHvbdGFR7Py9qL5v03bMbPxsA45oNaZAkzDhfTAqWhIAZsQ==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.411.tgz", + "integrity": "sha512-oZYZ7oX/7O+jzdTUcHkfD1A8YnNRW6mlUgdPjUg+5rXC43bwIdyatAnc0ObY21m9h8ghxGqholoLhm5WnGv1LQ==", "cpu": [ "arm64" ], @@ -530,9 +531,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.403.tgz", - "integrity": "sha512-eoswUc9vo4TB+/9PgFJLVtzI4dPjkpJXdCsAioVuoqPdNxHxlIHFe9HaVcqMRZxUNY1YHEBZozy+IpUEGjgdfQ==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.411.tgz", + "integrity": "sha512-nnXrKANmmGnkwa3ROlKdAhVNOx8daeMSE8Xh0o3ybKckFv4s38blhKdcxs0RJQRxgAk4p7XXGlDDKNRhurqF1g==", "cpu": [ "x64" ], @@ -547,9 +548,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.403.tgz", - "integrity": "sha512-djWjzCsp2xPNafMyOZ/ivU328/WvWhdroGie/DugiJBTgQL2SP0quWW1fhTlDwE81a3g9CxfJonaRgOpFTJTcg==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.411.tgz", + "integrity": "sha512-h+Bovb2YVCQSeELZOO7zxv8uht45XHcvAkFbRsc1gf9dl109sSUJIcB4KAhs8Aznk28qksxz7kvdSgUWyQBlIA==", "cpu": [ "arm64" ], @@ -564,9 +565,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.403", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.403.tgz", - "integrity": "sha512-lju8cHy2E6Ux7R7tWyLZeksYC2MVZu9i9ocjiBX/qfG2/pNJs7S5OlkwKJ0BSXSbZEHQYq7iHfEWp201bVfk9A==", + "version": "0.0.411", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.411.tgz", + "integrity": "sha512-xmOgi1lGvUBHQJWmq5AK1EP95+Y8xR4TFoK9OCSOaGbQ+LFcX2jF7iavnMolfWwddabew/AMQjsEHlXvbgMG8Q==", "cpu": [ "x64" ], @@ -580,6 +581,19 @@ "copilot-win32-x64": "copilot.exe" } }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -587,6 +601,47 @@ "dev": true, "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -1090,6 +1145,55 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1100,6 +1204,72 @@ "node": ">=12" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1110,6 +1280,163 @@ "node": ">=18" } }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1117,6 +1444,19 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1159,6 +1499,13 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1169,6 +1516,39 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1179,6 +1559,93 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1197,6 +1664,48 @@ } } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1212,19 +1721,220 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" }, "funding": { "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1235,6 +1945,73 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1254,6 +2031,39 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1265,6 +2075,29 @@ ], "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/openai": { "version": "6.17.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.17.0.tgz", @@ -1287,6 +2120,37 @@ } } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1314,6 +2178,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -1343,6 +2217,72 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1398,6 +2338,183 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1422,6 +2539,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -1473,6 +2600,16 @@ "node": ">=14.0.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1493,6 +2630,21 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1514,6 +2666,26 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -1667,6 +2839,22 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1684,6 +2872,13 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", @@ -1699,6 +2894,26 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/test/harness/package.json b/test/harness/package.json index 7a1a37ad5..65edf4cbc 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -11,7 +11,8 @@ "test": "vitest run" }, "devDependencies": { - "@github/copilot": "^0.0.403", + "@github/copilot": "^0.0.411", + "@modelcontextprotocol/sdk": "^1.26.0", "@types/node": "^25.2.0", "openai": "^6.17.0", "tsx": "^4.21.0", diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index 1602ef2ad..d3dab9dc2 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -631,6 +631,7 @@ function transformOpenAIRequestMessage( function normalizeUserMessage(content: string): string { return content .replace(/.*?<\/current_datetime>/g, "") + .replace(/[\s\S]*?<\/reminder>/g, "") .trim(); } diff --git a/test/harness/test-mcp-server.mjs b/test/harness/test-mcp-server.mjs new file mode 100644 index 000000000..b2b32606d --- /dev/null +++ b/test/harness/test-mcp-server.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Minimal MCP server that exposes a `get_env` tool. + * Returns the value of a named environment variable from this process. + * Used by SDK E2E tests to verify that literal env values reach MCP server subprocesses. + * + * Usage: npx tsx test-mcp-server.mjs + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +const server = new McpServer({ name: "env-echo", version: "1.0.0" }); + +server.tool( + "get_env", + "Returns the value of the specified environment variable.", + { name: z.string().describe("Environment variable name") }, + async ({ name }) => ({ + content: [{ type: "text", text: process.env[name] ?? "" }], + }), +); + +const transport = new StdioServerTransport(); +await server.connect(transport); + diff --git a/test/scenarios/.gitignore b/test/scenarios/.gitignore new file mode 100644 index 000000000..b56abbd20 --- /dev/null +++ b/test/scenarios/.gitignore @@ -0,0 +1,86 @@ +# Dependencies +node_modules/ +.venv/ +vendor/ + +# E2E run artifacts (agents may create files during verify.sh runs) +**/sessions/**/plan.md +**/tools/**/plan.md +**/callbacks/**/plan.md +**/prompts/**/plan.md + +# Build output +dist/ +target/ +build/ +*.exe +*.dll +*.so +*.dylib + +# Go +*.test +fully-bundled-go +app-direct-server-go +container-proxy-go +container-relay-go +app-backend-to-server-go +custom-agents-go +mcp-servers-go +no-tools-go +virtual-filesystem-go +system-message-go +skills-go +streaming-go +attachments-go +tool-filtering-go +permissions-go +hooks-go +user-input-go +concurrent-sessions-go +session-resume-go +stdio-go +tcp-go +gh-app-go +cli-preset-go +filesystem-preset-go +minimal-preset-go +default-go +minimal-go + +# Python +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +*.egg +.eggs/ + +# TypeScript +*.tsbuildinfo +package-lock.json + +# C# / .NET +bin/ +obj/ +*.csproj.nuget.* + +# IDE / OS +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Multi-user scenario temp directories +**/sessions/multi-user-long-lived/tmp/ + +# Logs +*.log +npm-debug.log* +infinite-sessions-go +reasoning-effort-go +reconnect-go +byok-openai-go +token-sources-go diff --git a/test/scenarios/README.md b/test/scenarios/README.md new file mode 100644 index 000000000..e45aac32f --- /dev/null +++ b/test/scenarios/README.md @@ -0,0 +1,38 @@ +# SDK E2E Scenario Tests + +End-to-end scenario tests for the Copilot SDK. Each scenario demonstrates a specific SDK capability with implementations in TypeScript, Python, and Go. + +## Structure + +``` +scenarios/ +├── auth/ # Authentication flows (OAuth, BYOK, token sources) +├── bundling/ # Deployment architectures (stdio, TCP, containers) +├── callbacks/ # Lifecycle hooks, permissions, user input +├── modes/ # Preset modes (CLI, filesystem, minimal) +├── prompts/ # Prompt configuration (attachments, system messages, reasoning) +├── sessions/ # Session management (streaming, resume, concurrent, infinite) +├── tools/ # Tool capabilities (custom agents, MCP, skills, filtering) +├── transport/ # Wire protocols (stdio, TCP, WASM, reconnect) +└── verify.sh # Run all scenarios +``` + +## Running + +Run all scenarios: + +```bash +COPILOT_CLI_PATH=/path/to/copilot GITHUB_TOKEN=$(gh auth token) bash verify.sh +``` + +Run a single scenario: + +```bash +COPILOT_CLI_PATH=/path/to/copilot GITHUB_TOKEN=$(gh auth token) bash //verify.sh +``` + +## Prerequisites + +- **Copilot CLI** — set `COPILOT_CLI_PATH` +- **GitHub token** — set `GITHUB_TOKEN` or use `gh auth login` +- **Node.js 20+**, **Python 3.10+**, **Go 1.24+** (per language) diff --git a/test/scenarios/auth/byok-anthropic/README.md b/test/scenarios/auth/byok-anthropic/README.md new file mode 100644 index 000000000..5fd4511dc --- /dev/null +++ b/test/scenarios/auth/byok-anthropic/README.md @@ -0,0 +1,37 @@ +# Auth Sample: BYOK Anthropic + +This sample shows how to use Copilot SDK in **BYOK** mode with an Anthropic provider. + +## What this sample does + +1. Creates a session with a custom provider (`type: "anthropic"`) +2. Uses your `ANTHROPIC_API_KEY` instead of GitHub auth +3. Sends a prompt and prints the response + +## Prerequisites + +- `copilot` binary (`COPILOT_CLI_PATH`, or auto-detected by SDK) +- Node.js 20+ +- `ANTHROPIC_API_KEY` + +## Run + +```bash +cd typescript +npm install --ignore-scripts +npm run build +ANTHROPIC_API_KEY=sk-ant-... node dist/index.js +``` + +Optional environment variables: + +- `ANTHROPIC_BASE_URL` (default: `https://api.anthropic.com`) +- `ANTHROPIC_MODEL` (default: `claude-sonnet-4-20250514`) + +## Verify + +```bash +./verify.sh +``` + +Build checks run by default. E2E run is optional and requires both `BYOK_SAMPLE_RUN_E2E=1` and `ANTHROPIC_API_KEY`. diff --git a/test/scenarios/auth/byok-anthropic/csharp/Program.cs b/test/scenarios/auth/byok-anthropic/csharp/Program.cs new file mode 100644 index 000000000..6bb9dd231 --- /dev/null +++ b/test/scenarios/auth/byok-anthropic/csharp/Program.cs @@ -0,0 +1,54 @@ +using GitHub.Copilot.SDK; + +var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY"); +var model = Environment.GetEnvironmentVariable("ANTHROPIC_MODEL") ?? "claude-sonnet-4-20250514"; +var baseUrl = Environment.GetEnvironmentVariable("ANTHROPIC_BASE_URL") ?? "https://api.anthropic.com"; + +if (string.IsNullOrEmpty(apiKey)) +{ + Console.Error.WriteLine("Missing ANTHROPIC_API_KEY."); + return 1; +} + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = model, + Provider = new ProviderConfig + { + Type = "anthropic", + BaseUrl = baseUrl, + ApiKey = apiKey, + }, + AvailableTools = [], + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Replace, + Content = "You are a helpful assistant. Answer concisely.", + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} +return 0; + diff --git a/test/scenarios/auth/byok-anthropic/csharp/csharp.csproj b/test/scenarios/auth/byok-anthropic/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/auth/byok-anthropic/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/auth/byok-anthropic/go/go.mod b/test/scenarios/auth/byok-anthropic/go/go.mod new file mode 100644 index 000000000..9a727c69c --- /dev/null +++ b/test/scenarios/auth/byok-anthropic/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/auth/byok-anthropic/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/auth/byok-anthropic/go/go.sum b/test/scenarios/auth/byok-anthropic/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/auth/byok-anthropic/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/auth/byok-anthropic/go/main.go b/test/scenarios/auth/byok-anthropic/go/main.go new file mode 100644 index 000000000..a42f90b8c --- /dev/null +++ b/test/scenarios/auth/byok-anthropic/go/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + apiKey := os.Getenv("ANTHROPIC_API_KEY") + if apiKey == "" { + log.Fatal("Missing ANTHROPIC_API_KEY.") + } + + baseUrl := os.Getenv("ANTHROPIC_BASE_URL") + if baseUrl == "" { + baseUrl = "https://api.anthropic.com" + } + + model := os.Getenv("ANTHROPIC_MODEL") + if model == "" { + model = "claude-sonnet-4-20250514" + } + + client := copilot.NewClient(&copilot.ClientOptions{}) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: model, + Provider: &copilot.ProviderConfig{ + Type: "anthropic", + BaseURL: baseUrl, + APIKey: apiKey, + }, + AvailableTools: []string{}, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: "You are a helpful assistant. Answer concisely.", + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/auth/byok-anthropic/python/main.py b/test/scenarios/auth/byok-anthropic/python/main.py new file mode 100644 index 000000000..7f5e5834c --- /dev/null +++ b/test/scenarios/auth/byok-anthropic/python/main.py @@ -0,0 +1,48 @@ +import asyncio +import os +import sys +from copilot import CopilotClient + +ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY") +ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-20250514") +ANTHROPIC_BASE_URL = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com") + +if not ANTHROPIC_API_KEY: + print("Missing ANTHROPIC_API_KEY.", file=sys.stderr) + sys.exit(1) + + +async def main(): + opts = {} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session({ + "model": ANTHROPIC_MODEL, + "provider": { + "type": "anthropic", + "base_url": ANTHROPIC_BASE_URL, + "api_key": ANTHROPIC_API_KEY, + }, + "available_tools": [], + "system_message": { + "mode": "replace", + "content": "You are a helpful assistant. Answer concisely.", + }, + }) + + response = await session.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/auth/byok-anthropic/python/requirements.txt b/test/scenarios/auth/byok-anthropic/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/auth/byok-anthropic/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/auth/byok-anthropic/typescript/package.json b/test/scenarios/auth/byok-anthropic/typescript/package.json new file mode 100644 index 000000000..4bb834ff2 --- /dev/null +++ b/test/scenarios/auth/byok-anthropic/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "auth-byok-anthropic-typescript", + "version": "1.0.0", + "private": true, + "description": "Auth sample — BYOK with Anthropic", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/auth/byok-anthropic/typescript/src/index.ts b/test/scenarios/auth/byok-anthropic/typescript/src/index.ts new file mode 100644 index 000000000..bd5f30dd0 --- /dev/null +++ b/test/scenarios/auth/byok-anthropic/typescript/src/index.ts @@ -0,0 +1,48 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const apiKey = process.env.ANTHROPIC_API_KEY; + const model = process.env.ANTHROPIC_MODEL || "claude-sonnet-4-20250514"; + + if (!apiKey) { + console.error("Required: ANTHROPIC_API_KEY"); + process.exit(1); + } + + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + }); + + try { + const session = await client.createSession({ + model, + provider: { + type: "anthropic", + baseUrl: process.env.ANTHROPIC_BASE_URL || "https://api.anthropic.com", + apiKey, + }, + availableTools: [], + systemMessage: { + mode: "replace", + content: "You are a helpful assistant. Answer concisely.", + }, + }); + + const response = await session.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/auth/byok-anthropic/verify.sh b/test/scenarios/auth/byok-anthropic/verify.sh new file mode 100755 index 000000000..24a8c7ca9 --- /dev/null +++ b/test/scenarios/auth/byok-anthropic/verify.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying auth/byok-anthropic" +echo "══════════════════════════════════════" +echo "" + +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +if [ "${BYOK_SAMPLE_RUN_E2E:-}" = "1" ] && [ -n "${ANTHROPIC_API_KEY:-}" ]; then + run_with_timeout "TypeScript (run)" bash -c " + cd '$SCRIPT_DIR/typescript' && \ + output=\$(node dist/index.js 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response\|hello' + " + run_with_timeout "C# (run)" bash -c " + cd '$SCRIPT_DIR/csharp' && \ + output=\$(dotnet run --no-build 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response\|hello' + " +else + echo "⚠️ WARNING: E2E run was SKIPPED — only build was verified, not runtime behavior." + echo " To run fully: set BYOK_SAMPLE_RUN_E2E=1 and ANTHROPIC_API_KEY." + echo "" +fi + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/auth/byok-azure/README.md b/test/scenarios/auth/byok-azure/README.md new file mode 100644 index 000000000..86843355f --- /dev/null +++ b/test/scenarios/auth/byok-azure/README.md @@ -0,0 +1,58 @@ +# Auth Sample: BYOK Azure OpenAI + +This sample shows how to use Copilot SDK in **BYOK** mode with an Azure OpenAI provider. + +## What this sample does + +1. Creates a session with a custom provider (`type: "azure"`) +2. Uses your Azure OpenAI endpoint and API key instead of GitHub auth +3. Configures the Azure-specific `apiVersion` field +4. Sends a prompt and prints the response + +## Prerequisites + +- `copilot` binary (`COPILOT_CLI_PATH`, or auto-detected by SDK) +- Node.js 20+ +- An Azure OpenAI resource with a deployed model + +## Run + +```bash +cd typescript +npm install --ignore-scripts +npm run build +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com AZURE_OPENAI_API_KEY=... node dist/index.js +``` + +### Environment variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `AZURE_OPENAI_ENDPOINT` | Yes | — | Azure OpenAI resource endpoint URL | +| `AZURE_OPENAI_API_KEY` | Yes | — | Azure OpenAI API key | +| `AZURE_OPENAI_MODEL` | No | `gpt-4.1` | Deployment / model name | +| `AZURE_API_VERSION` | No | `2024-10-21` | Azure OpenAI API version | +| `COPILOT_CLI_PATH` | No | auto-detected | Path to `copilot` binary | + +## Provider configuration + +The key difference from standard OpenAI BYOK is the `azure` block in the provider config: + +```typescript +provider: { + type: "azure", + baseUrl: endpoint, + apiKey, + azure: { + apiVersion: "2024-10-21", + }, +} +``` + +## Verify + +```bash +./verify.sh +``` + +Build checks run by default. E2E run requires `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_API_KEY` to be set. diff --git a/test/scenarios/auth/byok-azure/csharp/Program.cs b/test/scenarios/auth/byok-azure/csharp/Program.cs new file mode 100644 index 000000000..e6b2789a1 --- /dev/null +++ b/test/scenarios/auth/byok-azure/csharp/Program.cs @@ -0,0 +1,59 @@ +using GitHub.Copilot.SDK; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT"); +var apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY"); +var model = Environment.GetEnvironmentVariable("AZURE_OPENAI_MODEL") ?? "claude-haiku-4.5"; +var apiVersion = Environment.GetEnvironmentVariable("AZURE_API_VERSION") ?? "2024-10-21"; + +if (string.IsNullOrEmpty(endpoint) || string.IsNullOrEmpty(apiKey)) +{ + Console.Error.WriteLine("Required: AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY"); + return 1; +} + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = model, + Provider = new ProviderConfig + { + Type = "azure", + BaseUrl = endpoint, + ApiKey = apiKey, + Azure = new AzureOptions + { + ApiVersion = apiVersion, + }, + }, + AvailableTools = [], + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Replace, + Content = "You are a helpful assistant. Answer concisely.", + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} +return 0; + diff --git a/test/scenarios/auth/byok-azure/csharp/csharp.csproj b/test/scenarios/auth/byok-azure/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/auth/byok-azure/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/auth/byok-azure/go/go.mod b/test/scenarios/auth/byok-azure/go/go.mod new file mode 100644 index 000000000..f0dd08661 --- /dev/null +++ b/test/scenarios/auth/byok-azure/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/auth/byok-azure/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/auth/byok-azure/go/go.sum b/test/scenarios/auth/byok-azure/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/auth/byok-azure/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/auth/byok-azure/go/main.go b/test/scenarios/auth/byok-azure/go/main.go new file mode 100644 index 000000000..8d385076e --- /dev/null +++ b/test/scenarios/auth/byok-azure/go/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + endpoint := os.Getenv("AZURE_OPENAI_ENDPOINT") + apiKey := os.Getenv("AZURE_OPENAI_API_KEY") + if endpoint == "" || apiKey == "" { + log.Fatal("Required: AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY") + } + + model := os.Getenv("AZURE_OPENAI_MODEL") + if model == "" { + model = "claude-haiku-4.5" + } + + apiVersion := os.Getenv("AZURE_API_VERSION") + if apiVersion == "" { + apiVersion = "2024-10-21" + } + + client := copilot.NewClient(&copilot.ClientOptions{}) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: model, + Provider: &copilot.ProviderConfig{ + Type: "azure", + BaseURL: endpoint, + APIKey: apiKey, + Azure: &copilot.AzureProviderOptions{ + APIVersion: apiVersion, + }, + }, + AvailableTools: []string{}, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: "You are a helpful assistant. Answer concisely.", + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/auth/byok-azure/python/main.py b/test/scenarios/auth/byok-azure/python/main.py new file mode 100644 index 000000000..5376cac28 --- /dev/null +++ b/test/scenarios/auth/byok-azure/python/main.py @@ -0,0 +1,52 @@ +import asyncio +import os +import sys +from copilot import CopilotClient + +AZURE_OPENAI_ENDPOINT = os.environ.get("AZURE_OPENAI_ENDPOINT") +AZURE_OPENAI_API_KEY = os.environ.get("AZURE_OPENAI_API_KEY") +AZURE_OPENAI_MODEL = os.environ.get("AZURE_OPENAI_MODEL", "claude-haiku-4.5") +AZURE_API_VERSION = os.environ.get("AZURE_API_VERSION", "2024-10-21") + +if not AZURE_OPENAI_ENDPOINT or not AZURE_OPENAI_API_KEY: + print("Required: AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY", file=sys.stderr) + sys.exit(1) + + +async def main(): + opts = {} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session({ + "model": AZURE_OPENAI_MODEL, + "provider": { + "type": "azure", + "base_url": AZURE_OPENAI_ENDPOINT, + "api_key": AZURE_OPENAI_API_KEY, + "azure": { + "api_version": AZURE_API_VERSION, + }, + }, + "available_tools": [], + "system_message": { + "mode": "replace", + "content": "You are a helpful assistant. Answer concisely.", + }, + }) + + response = await session.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/auth/byok-azure/python/requirements.txt b/test/scenarios/auth/byok-azure/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/auth/byok-azure/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/auth/byok-azure/typescript/package.json b/test/scenarios/auth/byok-azure/typescript/package.json new file mode 100644 index 000000000..2643625fd --- /dev/null +++ b/test/scenarios/auth/byok-azure/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "auth-byok-azure-typescript", + "version": "1.0.0", + "private": true, + "description": "Auth sample — BYOK with Azure OpenAI", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/auth/byok-azure/typescript/src/index.ts b/test/scenarios/auth/byok-azure/typescript/src/index.ts new file mode 100644 index 000000000..450742f86 --- /dev/null +++ b/test/scenarios/auth/byok-azure/typescript/src/index.ts @@ -0,0 +1,52 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const endpoint = process.env.AZURE_OPENAI_ENDPOINT; + const apiKey = process.env.AZURE_OPENAI_API_KEY; + const model = process.env.AZURE_OPENAI_MODEL || "claude-haiku-4.5"; + + if (!endpoint || !apiKey) { + console.error("Required: AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY"); + process.exit(1); + } + + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + }); + + try { + const session = await client.createSession({ + model, + provider: { + type: "azure", + baseUrl: endpoint, + apiKey, + azure: { + apiVersion: process.env.AZURE_API_VERSION || "2024-10-21", + }, + }, + availableTools: [], + systemMessage: { + mode: "replace", + content: "You are a helpful assistant. Answer concisely.", + }, + }); + + const response = await session.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/auth/byok-azure/verify.sh b/test/scenarios/auth/byok-azure/verify.sh new file mode 100755 index 000000000..bc43a68db --- /dev/null +++ b/test/scenarios/auth/byok-azure/verify.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying auth/byok-azure" +echo "══════════════════════════════════════" +echo "" + +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +if [ -n "${AZURE_OPENAI_ENDPOINT:-}" ] && [ -n "${AZURE_OPENAI_API_KEY:-}" ]; then + run_with_timeout "TypeScript (run)" bash -c " + cd '$SCRIPT_DIR/typescript' && \ + output=\$(node dist/index.js 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response\|hello' + " + run_with_timeout "C# (run)" bash -c " + cd '$SCRIPT_DIR/csharp' && \ + output=\$(dotnet run --no-build 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response\|hello' + " +else + echo "⚠️ WARNING: E2E run was SKIPPED — only build was verified, not runtime behavior." + echo " To run fully: set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY." + echo "" +fi + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/auth/byok-ollama/README.md b/test/scenarios/auth/byok-ollama/README.md new file mode 100644 index 000000000..74d4f237b --- /dev/null +++ b/test/scenarios/auth/byok-ollama/README.md @@ -0,0 +1,41 @@ +# Auth Sample: BYOK Ollama (Compact Context) + +This sample shows BYOK with **local Ollama** and intentionally trims session context so it works better with smaller local models. + +## What this sample does + +1. Uses a custom provider pointed at Ollama (`http://localhost:11434/v1`) +2. Replaces the default system prompt with a short compact prompt +3. Sets `availableTools: []` to remove built-in tool definitions from model context +4. Sends a prompt and prints the response + +This creates a small assistant profile suitable for constrained context windows. + +## Prerequisites + +- `copilot` binary (`COPILOT_CLI_PATH`, or auto-detected by SDK) +- Node.js 20+ +- Ollama running locally (`ollama serve`) +- A local model pulled (for example: `ollama pull llama3.2:3b`) + +## Run + +```bash +cd typescript +npm install --ignore-scripts +npm run build +node dist/index.js +``` + +Optional environment variables: + +- `OLLAMA_BASE_URL` (default: `http://localhost:11434/v1`) +- `OLLAMA_MODEL` (default: `llama3.2:3b`) + +## Verify + +```bash +./verify.sh +``` + +Build checks run by default. E2E run is optional and requires `BYOK_SAMPLE_RUN_E2E=1`. diff --git a/test/scenarios/auth/byok-ollama/csharp/Program.cs b/test/scenarios/auth/byok-ollama/csharp/Program.cs new file mode 100644 index 000000000..585157b66 --- /dev/null +++ b/test/scenarios/auth/byok-ollama/csharp/Program.cs @@ -0,0 +1,47 @@ +using GitHub.Copilot.SDK; + +var baseUrl = Environment.GetEnvironmentVariable("OLLAMA_BASE_URL") ?? "http://localhost:11434/v1"; +var model = Environment.GetEnvironmentVariable("OLLAMA_MODEL") ?? "llama3.2:3b"; + +var compactSystemPrompt = + "You are a compact local assistant. Keep answers short, concrete, and under 80 words."; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = model, + Provider = new ProviderConfig + { + Type = "openai", + BaseUrl = baseUrl, + }, + AvailableTools = [], + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Replace, + Content = compactSystemPrompt, + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/auth/byok-ollama/csharp/csharp.csproj b/test/scenarios/auth/byok-ollama/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/auth/byok-ollama/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/auth/byok-ollama/go/go.mod b/test/scenarios/auth/byok-ollama/go/go.mod new file mode 100644 index 000000000..806aaa5c2 --- /dev/null +++ b/test/scenarios/auth/byok-ollama/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/auth/byok-ollama/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/auth/byok-ollama/go/go.sum b/test/scenarios/auth/byok-ollama/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/auth/byok-ollama/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/auth/byok-ollama/go/main.go b/test/scenarios/auth/byok-ollama/go/main.go new file mode 100644 index 000000000..191d2eab7 --- /dev/null +++ b/test/scenarios/auth/byok-ollama/go/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +const compactSystemPrompt = "You are a compact local assistant. Keep answers short, concrete, and under 80 words." + +func main() { + baseUrl := os.Getenv("OLLAMA_BASE_URL") + if baseUrl == "" { + baseUrl = "http://localhost:11434/v1" + } + + model := os.Getenv("OLLAMA_MODEL") + if model == "" { + model = "llama3.2:3b" + } + + client := copilot.NewClient(&copilot.ClientOptions{}) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: model, + Provider: &copilot.ProviderConfig{ + Type: "openai", + BaseURL: baseUrl, + }, + AvailableTools: []string{}, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: compactSystemPrompt, + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/auth/byok-ollama/python/main.py b/test/scenarios/auth/byok-ollama/python/main.py new file mode 100644 index 000000000..0f9df7f54 --- /dev/null +++ b/test/scenarios/auth/byok-ollama/python/main.py @@ -0,0 +1,46 @@ +import asyncio +import os +import sys +from copilot import CopilotClient + +OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434/v1") +OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.2:3b") + +COMPACT_SYSTEM_PROMPT = ( + "You are a compact local assistant. Keep answers short, concrete, and under 80 words." +) + + +async def main(): + opts = {} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session({ + "model": OLLAMA_MODEL, + "provider": { + "type": "openai", + "base_url": OLLAMA_BASE_URL, + }, + "available_tools": [], + "system_message": { + "mode": "replace", + "content": COMPACT_SYSTEM_PROMPT, + }, + }) + + response = await session.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/auth/byok-ollama/python/requirements.txt b/test/scenarios/auth/byok-ollama/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/auth/byok-ollama/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/auth/byok-ollama/typescript/package.json b/test/scenarios/auth/byok-ollama/typescript/package.json new file mode 100644 index 000000000..e6ed3752d --- /dev/null +++ b/test/scenarios/auth/byok-ollama/typescript/package.json @@ -0,0 +1,19 @@ +{ + "name": "auth-byok-ollama-typescript", + "version": "1.0.0", + "private": true, + "description": "BYOK Ollama sample with compact context settings", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.5.0" + } +} diff --git a/test/scenarios/auth/byok-ollama/typescript/src/index.ts b/test/scenarios/auth/byok-ollama/typescript/src/index.ts new file mode 100644 index 000000000..3ba9da89d --- /dev/null +++ b/test/scenarios/auth/byok-ollama/typescript/src/index.ts @@ -0,0 +1,43 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL ?? "http://localhost:11434/v1"; +const OLLAMA_MODEL = process.env.OLLAMA_MODEL ?? "llama3.2:3b"; + +const COMPACT_SYSTEM_PROMPT = + "You are a compact local assistant. Keep answers short, concrete, and under 80 words."; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + }); + + try { + const session = await client.createSession({ + model: OLLAMA_MODEL, + provider: { + type: "openai", + baseUrl: OLLAMA_BASE_URL, + }, + // Use a compact replacement prompt and no tools to minimize request context. + systemMessage: { mode: "replace", content: COMPACT_SYSTEM_PROMPT }, + availableTools: [], + }); + + const response = await session.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/auth/byok-ollama/verify.sh b/test/scenarios/auth/byok-ollama/verify.sh new file mode 100755 index 000000000..c9a132a93 --- /dev/null +++ b/test/scenarios/auth/byok-ollama/verify.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying auth/byok-ollama" +echo "══════════════════════════════════════" +echo "" + +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +if [ "${BYOK_SAMPLE_RUN_E2E:-}" = "1" ]; then + run_with_timeout "TypeScript (run)" bash -c " + cd '$SCRIPT_DIR/typescript' && \ + output=\$(node dist/index.js 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response\|hello' + " + run_with_timeout "C# (run)" bash -c " + cd '$SCRIPT_DIR/csharp' && \ + output=\$(dotnet run --no-build 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response\|hello' + " +else + echo "⚠️ WARNING: E2E run was SKIPPED — only build was verified, not runtime behavior." + echo " To run fully: set BYOK_SAMPLE_RUN_E2E=1 (and ensure Ollama is running)." + echo "" +fi + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/auth/byok-openai/README.md b/test/scenarios/auth/byok-openai/README.md new file mode 100644 index 000000000..ace65cace --- /dev/null +++ b/test/scenarios/auth/byok-openai/README.md @@ -0,0 +1,37 @@ +# Auth Sample: BYOK OpenAI + +This sample shows how to use Copilot SDK in **BYOK** mode with an OpenAI-compatible provider. + +## What this sample does + +1. Creates a session with a custom provider (`type: "openai"`) +2. Uses your `OPENAI_API_KEY` instead of GitHub auth +3. Sends a prompt and prints the response + +## Prerequisites + +- `copilot` binary (`COPILOT_CLI_PATH`, or auto-detected by SDK) +- Node.js 20+ +- `OPENAI_API_KEY` + +## Run + +```bash +cd typescript +npm install --ignore-scripts +npm run build +OPENAI_API_KEY=sk-... node dist/index.js +``` + +Optional environment variables: + +- `OPENAI_BASE_URL` (default: `https://api.openai.com/v1`) +- `OPENAI_MODEL` (default: `gpt-4.1-mini`) + +## Verify + +```bash +./verify.sh +``` + +Build checks run by default. E2E run is optional and requires both `BYOK_SAMPLE_RUN_E2E=1` and `OPENAI_API_KEY`. diff --git a/test/scenarios/auth/byok-openai/csharp/Program.cs b/test/scenarios/auth/byok-openai/csharp/Program.cs new file mode 100644 index 000000000..5d549bd5c --- /dev/null +++ b/test/scenarios/auth/byok-openai/csharp/Program.cs @@ -0,0 +1,48 @@ +using GitHub.Copilot.SDK; + +var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY"); +var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "claude-haiku-4.5"; +var baseUrl = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? "https://api.openai.com/v1"; + +if (string.IsNullOrEmpty(apiKey)) +{ + Console.Error.WriteLine("Missing OPENAI_API_KEY."); + return 1; +} + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = model, + Provider = new ProviderConfig + { + Type = "openai", + BaseUrl = baseUrl, + ApiKey = apiKey, + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} +return 0; + diff --git a/test/scenarios/auth/byok-openai/csharp/csharp.csproj b/test/scenarios/auth/byok-openai/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/auth/byok-openai/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/auth/byok-openai/go/go.mod b/test/scenarios/auth/byok-openai/go/go.mod new file mode 100644 index 000000000..2d5a75ecf --- /dev/null +++ b/test/scenarios/auth/byok-openai/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/auth/byok-openai/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/auth/byok-openai/go/go.sum b/test/scenarios/auth/byok-openai/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/auth/byok-openai/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/auth/byok-openai/go/main.go b/test/scenarios/auth/byok-openai/go/main.go new file mode 100644 index 000000000..bd418ab71 --- /dev/null +++ b/test/scenarios/auth/byok-openai/go/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + log.Fatal("Missing OPENAI_API_KEY.") + } + + baseUrl := os.Getenv("OPENAI_BASE_URL") + if baseUrl == "" { + baseUrl = "https://api.openai.com/v1" + } + + model := os.Getenv("OPENAI_MODEL") + if model == "" { + model = "claude-haiku-4.5" + } + + client := copilot.NewClient(&copilot.ClientOptions{}) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: model, + Provider: &copilot.ProviderConfig{ + Type: "openai", + BaseURL: baseUrl, + APIKey: apiKey, + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/auth/byok-openai/python/main.py b/test/scenarios/auth/byok-openai/python/main.py new file mode 100644 index 000000000..651a92cd6 --- /dev/null +++ b/test/scenarios/auth/byok-openai/python/main.py @@ -0,0 +1,43 @@ +import asyncio +import os +import sys +from copilot import CopilotClient + +OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1") +OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "claude-haiku-4.5") +OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY") + +if not OPENAI_API_KEY: + print("Missing OPENAI_API_KEY.", file=sys.stderr) + sys.exit(1) + + +async def main(): + opts = {} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session({ + "model": OPENAI_MODEL, + "provider": { + "type": "openai", + "base_url": OPENAI_BASE_URL, + "api_key": OPENAI_API_KEY, + }, + }) + + response = await session.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/auth/byok-openai/python/requirements.txt b/test/scenarios/auth/byok-openai/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/auth/byok-openai/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/auth/byok-openai/typescript/package.json b/test/scenarios/auth/byok-openai/typescript/package.json new file mode 100644 index 000000000..ecfaae878 --- /dev/null +++ b/test/scenarios/auth/byok-openai/typescript/package.json @@ -0,0 +1,19 @@ +{ + "name": "auth-byok-openai-typescript", + "version": "1.0.0", + "private": true, + "description": "BYOK OpenAI provider sample for Copilot SDK", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.5.0" + } +} diff --git a/test/scenarios/auth/byok-openai/typescript/src/index.ts b/test/scenarios/auth/byok-openai/typescript/src/index.ts new file mode 100644 index 000000000..1d2d0aaf8 --- /dev/null +++ b/test/scenarios/auth/byok-openai/typescript/src/index.ts @@ -0,0 +1,44 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1"; +const OPENAI_MODEL = process.env.OPENAI_MODEL ?? "claude-haiku-4.5"; +const OPENAI_API_KEY = process.env.OPENAI_API_KEY; + +if (!OPENAI_API_KEY) { + console.error("Missing OPENAI_API_KEY."); + process.exit(1); +} + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + }); + + try { + const session = await client.createSession({ + model: OPENAI_MODEL, + provider: { + type: "openai", + baseUrl: OPENAI_BASE_URL, + apiKey: OPENAI_API_KEY, + }, + }); + + const response = await session.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/auth/byok-openai/verify.sh b/test/scenarios/auth/byok-openai/verify.sh new file mode 100755 index 000000000..1fa205e2b --- /dev/null +++ b/test/scenarios/auth/byok-openai/verify.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying auth/byok-openai" +echo "══════════════════════════════════════" +echo "" + +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o byok-openai-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +if [ "${BYOK_SAMPLE_RUN_E2E:-}" = "1" ] && [ -n "${OPENAI_API_KEY:-}" ]; then + run_with_timeout "TypeScript (run)" bash -c " + cd '$SCRIPT_DIR/typescript' && \ + output=\$(node dist/index.js 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response\|hello' + " + run_with_timeout "Python (run)" bash -c " + cd '$SCRIPT_DIR/python' && \ + output=\$(python3 main.py 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response\|hello' + " + run_with_timeout "Go (run)" bash -c " + cd '$SCRIPT_DIR/go' && \ + output=\$(./byok-openai-go 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response\|hello' + " + run_with_timeout "C# (run)" bash -c " + cd '$SCRIPT_DIR/csharp' && \ + output=\$(dotnet run --no-build 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response\|hello' + " +else + echo "⚠️ WARNING: E2E run was SKIPPED — only build was verified, not runtime behavior." + echo " To run fully: set BYOK_SAMPLE_RUN_E2E=1 and OPENAI_API_KEY." + echo "" +fi + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/auth/gh-app/README.md b/test/scenarios/auth/gh-app/README.md new file mode 100644 index 000000000..0b1bf4f1f --- /dev/null +++ b/test/scenarios/auth/gh-app/README.md @@ -0,0 +1,55 @@ +# Auth Sample: GitHub OAuth App (Scenario 1) + +This scenario demonstrates how a packaged app can let end users sign in with GitHub using OAuth Device Flow, then use that user token to call Copilot with their own subscription. + +## What this sample does + +1. Starts GitHub OAuth Device Flow +2. Prompts the user to open the verification URL and enter the code +3. Polls for the access token +4. Fetches the signed-in user profile +5. Calls Copilot with that OAuth token (SDK clients in TypeScript/Python/Go) + +## Prerequisites + +- A GitHub OAuth App client ID (`GITHUB_OAUTH_CLIENT_ID`) +- `copilot` binary (`COPILOT_CLI_PATH`, or auto-detected by SDK) +- Node.js 20+ +- Python 3.10+ +- Go 1.24+ + +## Run + +### TypeScript + +```bash +cd typescript +npm install --ignore-scripts +npm run build +GITHUB_OAUTH_CLIENT_ID=Ivxxxxxxxxxxxx node dist/index.js +``` + +### Python + +```bash +cd python +pip3 install -r requirements.txt --quiet +GITHUB_OAUTH_CLIENT_ID=Ivxxxxxxxxxxxx python3 main.py +``` + +### Go + +```bash +cd go +go run main.go +``` + +## Verify + +```bash +./verify.sh +``` + +`verify.sh` checks install/build for all languages. Interactive runs are skipped by default and can be enabled by setting both `GITHUB_OAUTH_CLIENT_ID` and `AUTH_SAMPLE_RUN_INTERACTIVE=1`. + +To include this sample in the full suite, run `./verify.sh` from the `samples/` root. diff --git a/test/scenarios/auth/gh-app/csharp/Program.cs b/test/scenarios/auth/gh-app/csharp/Program.cs new file mode 100644 index 000000000..70f5f379c --- /dev/null +++ b/test/scenarios/auth/gh-app/csharp/Program.cs @@ -0,0 +1,89 @@ +using System.Net.Http.Json; +using System.Text.Json; +using GitHub.Copilot.SDK; + +// GitHub OAuth Device Flow +var clientId = Environment.GetEnvironmentVariable("GITHUB_OAUTH_CLIENT_ID") + ?? throw new InvalidOperationException("Missing GITHUB_OAUTH_CLIENT_ID"); + +var httpClient = new HttpClient(); +httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); +httpClient.DefaultRequestHeaders.Add("User-Agent", "copilot-sdk-csharp"); + +// Step 1: Request device code +var deviceCodeResponse = await httpClient.PostAsync( + "https://github.com/login/device/code", + new FormUrlEncodedContent(new Dictionary { { "client_id", clientId } })); +var deviceCode = await deviceCodeResponse.Content.ReadFromJsonAsync(); + +var userCode = deviceCode.GetProperty("user_code").GetString(); +var verificationUri = deviceCode.GetProperty("verification_uri").GetString(); +var code = deviceCode.GetProperty("device_code").GetString(); +var interval = deviceCode.GetProperty("interval").GetInt32(); + +Console.WriteLine($"Please visit: {verificationUri}"); +Console.WriteLine($"Enter code: {userCode}"); + +// Step 2: Poll for access token +string? accessToken = null; +while (accessToken == null) +{ + await Task.Delay(interval * 1000); + var tokenResponse = await httpClient.PostAsync( + "https://github.com/login/oauth/access_token", + new FormUrlEncodedContent(new Dictionary + { + { "client_id", clientId }, + { "device_code", code! }, + { "grant_type", "urn:ietf:params:oauth:grant-type:device_code" }, + })); + var tokenData = await tokenResponse.Content.ReadFromJsonAsync(); + + if (tokenData.TryGetProperty("access_token", out var token)) + { + accessToken = token.GetString(); + } + else if (tokenData.TryGetProperty("error", out var error)) + { + var err = error.GetString(); + if (err == "authorization_pending") continue; + if (err == "slow_down") { interval += 5; continue; } + throw new Exception($"OAuth error: {err}"); + } +} + +// Step 3: Verify authentication +httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {accessToken}"); +var userResponse = await httpClient.GetFromJsonAsync("https://api.github.com/user"); +Console.WriteLine($"Authenticated as: {userResponse.GetProperty("login").GetString()}"); + +// Step 4: Use the token with Copilot +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = accessToken, +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/auth/gh-app/csharp/csharp.csproj b/test/scenarios/auth/gh-app/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/auth/gh-app/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/auth/gh-app/go/go.mod b/test/scenarios/auth/gh-app/go/go.mod new file mode 100644 index 000000000..a0d270c6e --- /dev/null +++ b/test/scenarios/auth/gh-app/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/auth/gh-app/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/auth/gh-app/go/go.sum b/test/scenarios/auth/gh-app/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/auth/gh-app/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/auth/gh-app/go/main.go b/test/scenarios/auth/gh-app/go/main.go new file mode 100644 index 000000000..d26594779 --- /dev/null +++ b/test/scenarios/auth/gh-app/go/main.go @@ -0,0 +1,191 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "time" + + copilot "github.com/github/copilot-sdk/go" +) + +const ( + deviceCodeURL = "https://github.com/login/device/code" + accessTokenURL = "https://github.com/login/oauth/access_token" + userURL = "https://api.github.com/user" +) + +type deviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + Interval int `json:"interval"` +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + Interval int `json:"interval"` +} + +type githubUser struct { + Login string `json:"login"` + Name string `json:"name"` +} + +func postJSON(url string, payload any, target any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + responseBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("request failed: %s %s", resp.Status, string(responseBody)) + } + return json.NewDecoder(resp.Body).Decode(target) +} + +func getUser(token string) (*githubUser, error) { + req, err := http.NewRequest(http.MethodGet, userURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", "copilot-sdk-samples-auth-gh-app") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + responseBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("github API failed: %s %s", resp.Status, string(responseBody)) + } + var user githubUser + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, err + } + return &user, nil +} + +func startDeviceFlow(clientID string) (*deviceCodeResponse, error) { + var resp deviceCodeResponse + err := postJSON(deviceCodeURL, map[string]any{ + "client_id": clientID, + "scope": "read:user", + }, &resp) + return &resp, err +} + +func pollForToken(clientID, deviceCode string, interval int) (string, error) { + delaySeconds := interval + for { + time.Sleep(time.Duration(delaySeconds) * time.Second) + var resp tokenResponse + if err := postJSON(accessTokenURL, map[string]any{ + "client_id": clientID, + "device_code": deviceCode, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, &resp); err != nil { + return "", err + } + if resp.AccessToken != "" { + return resp.AccessToken, nil + } + if resp.Error == "authorization_pending" { + continue + } + if resp.Error == "slow_down" { + if resp.Interval > 0 { + delaySeconds = resp.Interval + } else { + delaySeconds += 5 + } + continue + } + if resp.ErrorDescription != "" { + return "", fmt.Errorf(resp.ErrorDescription) + } + if resp.Error != "" { + return "", fmt.Errorf(resp.Error) + } + return "", fmt.Errorf("OAuth polling failed") + } +} + +func main() { + clientID := os.Getenv("GITHUB_OAUTH_CLIENT_ID") + if clientID == "" { + log.Fatal("Missing GITHUB_OAUTH_CLIENT_ID") + } + + fmt.Println("Starting GitHub OAuth device flow...") + device, err := startDeviceFlow(clientID) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Open %s and enter code: %s\n", device.VerificationURI, device.UserCode) + fmt.Print("Press Enter after you authorize this app...") + fmt.Scanln() + + token, err := pollForToken(clientID, device.DeviceCode, device.Interval) + if err != nil { + log.Fatal(err) + } + + user, err := getUser(token) + if err != nil { + log.Fatal(err) + } + if user.Name != "" { + fmt.Printf("Authenticated as: %s (%s)\n", user.Login, user.Name) + } else { + fmt.Printf("Authenticated as: %s\n", user.Login) + } + + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: token, + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/auth/gh-app/python/main.py b/test/scenarios/auth/gh-app/python/main.py new file mode 100644 index 000000000..4568c82b2 --- /dev/null +++ b/test/scenarios/auth/gh-app/python/main.py @@ -0,0 +1,97 @@ +import asyncio +import json +import os +import time +import urllib.request + +from copilot import CopilotClient + + +DEVICE_CODE_URL = "https://github.com/login/device/code" +ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" +USER_URL = "https://api.github.com/user" + + +def post_json(url: str, payload: dict) -> dict: + req = urllib.request.Request( + url=url, + data=json.dumps(payload).encode("utf-8"), + headers={"Accept": "application/json", "Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req) as response: + return json.loads(response.read().decode("utf-8")) + + +def get_json(url: str, token: str) -> dict: + req = urllib.request.Request( + url=url, + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {token}", + "User-Agent": "copilot-sdk-samples-auth-gh-app", + }, + method="GET", + ) + with urllib.request.urlopen(req) as response: + return json.loads(response.read().decode("utf-8")) + + +def start_device_flow(client_id: str) -> dict: + return post_json(DEVICE_CODE_URL, {"client_id": client_id, "scope": "read:user"}) + + +def poll_for_access_token(client_id: str, device_code: str, interval: int) -> str: + delay_seconds = interval + while True: + time.sleep(delay_seconds) + data = post_json( + ACCESS_TOKEN_URL, + { + "client_id": client_id, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + ) + if data.get("access_token"): + return data["access_token"] + if data.get("error") == "authorization_pending": + continue + if data.get("error") == "slow_down": + delay_seconds = int(data.get("interval", delay_seconds + 5)) + continue + raise RuntimeError(data.get("error_description") or data.get("error") or "OAuth polling failed") + + +async def main(): + client_id = os.environ.get("GITHUB_OAUTH_CLIENT_ID") + if not client_id: + raise RuntimeError("Missing GITHUB_OAUTH_CLIENT_ID") + + print("Starting GitHub OAuth device flow...") + device = start_device_flow(client_id) + print(f"Open {device['verification_uri']} and enter code: {device['user_code']}") + input("Press Enter after you authorize this app...") + + token = poll_for_access_token(client_id, device["device_code"], int(device["interval"])) + user = get_json(USER_URL, token) + display_name = f" ({user.get('name')})" if user.get("name") else "" + print(f"Authenticated as: {user.get('login')}{display_name}") + + opts = {"github_token": token} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session({"model": "claude-haiku-4.5"}) + response = await session.send_and_wait({"prompt": "What is the capital of France?"}) + if response: + print(response.data.content) + await session.destroy() + finally: + await client.stop() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/test/scenarios/auth/gh-app/python/requirements.txt b/test/scenarios/auth/gh-app/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/auth/gh-app/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/auth/gh-app/typescript/package.json b/test/scenarios/auth/gh-app/typescript/package.json new file mode 100644 index 000000000..1cdcd9602 --- /dev/null +++ b/test/scenarios/auth/gh-app/typescript/package.json @@ -0,0 +1,19 @@ +{ + "name": "auth-gh-app-typescript", + "version": "1.0.0", + "private": true, + "description": "GitHub OAuth App device flow sample for Copilot SDK", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.5.0" + } +} diff --git a/test/scenarios/auth/gh-app/typescript/src/index.ts b/test/scenarios/auth/gh-app/typescript/src/index.ts new file mode 100644 index 000000000..1c9cabde3 --- /dev/null +++ b/test/scenarios/auth/gh-app/typescript/src/index.ts @@ -0,0 +1,133 @@ +import { CopilotClient } from "@github/copilot-sdk"; +import readline from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; + +type DeviceCodeResponse = { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +}; + +type OAuthTokenResponse = { + access_token?: string; + error?: string; + error_description?: string; + interval?: number; +}; + +type GitHubUser = { + login: string; + name: string | null; +}; + +const DEVICE_CODE_URL = "https://github.com/login/device/code"; +const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; +const USER_URL = "https://api.github.com/user"; + +const CLIENT_ID = process.env.GITHUB_OAUTH_CLIENT_ID; + +if (!CLIENT_ID) { + console.error("Missing GITHUB_OAUTH_CLIENT_ID."); + process.exit(1); +} + +async function postJson(url: string, body: Record): Promise { + const response = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Request failed: ${response.status} ${response.statusText}`); + } + + return (await response.json()) as T; +} + +async function getJson(url: string, token: string): Promise { + const response = await fetch(url, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + "User-Agent": "copilot-sdk-samples-auth-gh-app", + }, + }); + + if (!response.ok) { + throw new Error(`GitHub API failed: ${response.status} ${response.statusText}`); + } + + return (await response.json()) as T; +} + +async function startDeviceFlow(): Promise { + return postJson(DEVICE_CODE_URL, { + client_id: CLIENT_ID, + scope: "read:user", + }); +} + +async function pollForAccessToken(deviceCode: string, intervalSeconds: number): Promise { + let interval = intervalSeconds; + + while (true) { + await new Promise((resolve) => setTimeout(resolve, interval * 1000)); + + const data = await postJson(ACCESS_TOKEN_URL, { + client_id: CLIENT_ID, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }); + + if (data.access_token) return data.access_token; + if (data.error === "authorization_pending") continue; + if (data.error === "slow_down") { + interval = data.interval ?? interval + 5; + continue; + } + + throw new Error(data.error_description ?? data.error ?? "OAuth token polling failed"); + } +} + +async function main() { + console.log("Starting GitHub OAuth device flow..."); + const device = await startDeviceFlow(); + + console.log(`Open ${device.verification_uri} and enter code: ${device.user_code}`); + const rl = readline.createInterface({ input, output }); + await rl.question("Press Enter after you authorize this app..."); + rl.close(); + + const accessToken = await pollForAccessToken(device.device_code, device.interval); + const user = await getJson(USER_URL, accessToken); + console.log(`Authenticated as: ${user.login}${user.name ? ` (${user.name})` : ""}`); + + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: accessToken, + }); + + try { + const session = await client.createSession({ model: "claude-haiku-4.5" }); + const response = await session.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response) console.log(response.data.content); + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/scenarios/auth/gh-app/verify.sh b/test/scenarios/auth/gh-app/verify.sh new file mode 100755 index 000000000..5d2ae20c0 --- /dev/null +++ b/test/scenarios/auth/gh-app/verify.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=180 + +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying auth/gh-app scenario 1" +echo "══════════════════════════════════════" +echo "" + +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go mod tidy && go build -o gh-app-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +if [ -n "${GITHUB_OAUTH_CLIENT_ID:-}" ] && [ "${AUTH_SAMPLE_RUN_INTERACTIVE:-}" = "1" ]; then + run_with_timeout "TypeScript (run)" bash -c " + cd '$SCRIPT_DIR/typescript' && \ + output=\$(printf '\\n' | node dist/index.js 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'device\|code\|http\|login\|verify\|oauth\|github' + " + run_with_timeout "Python (run)" bash -c " + cd '$SCRIPT_DIR/python' && \ + output=\$(printf '\\n' | python3 main.py 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'device\|code\|http\|login\|verify\|oauth\|github' + " + run_with_timeout "Go (run)" bash -c " + cd '$SCRIPT_DIR/go' && \ + output=\$(printf '\\n' | ./gh-app-go 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'device\|code\|http\|login\|verify\|oauth\|github' + " + run_with_timeout "C# (run)" bash -c " + cd '$SCRIPT_DIR/csharp' && \ + output=\$(printf '\\n' | dotnet run --no-build 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'device\|code\|http\|login\|verify\|oauth\|github' + " +else + echo "⚠️ WARNING: E2E run was SKIPPED — only build was verified, not runtime behavior." + echo " To run fully: set GITHUB_OAUTH_CLIENT_ID and AUTH_SAMPLE_RUN_INTERACTIVE=1." + echo "" +fi + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/bundling/app-backend-to-server/README.md b/test/scenarios/bundling/app-backend-to-server/README.md new file mode 100644 index 000000000..dd4e4b7f6 --- /dev/null +++ b/test/scenarios/bundling/app-backend-to-server/README.md @@ -0,0 +1,99 @@ +# App-Backend-to-Server Samples + +Samples that demonstrate the **app-backend-to-server** deployment architecture of the Copilot SDK. In this scenario a web backend connects to a **pre-running** `copilot` TCP server and exposes a `POST /chat` HTTP endpoint. The HTTP server receives a prompt from the client, forwards it to Copilot CLI, and returns the response. + +``` +┌────────┐ HTTP POST /chat ┌─────────────┐ TCP (JSON-RPC) ┌──────────────┐ +│ Client │ ──────────────────▶ │ Web Backend │ ─────────────────▶ │ Copilot CLI │ +│ (curl) │ ◀────────────────── │ (HTTP server)│ ◀───────────────── │ (TCP server) │ +└────────┘ └─────────────┘ └──────────────┘ +``` + +Each sample follows the same flow: + +1. **Start** an HTTP server with a `POST /chat` endpoint +2. **Receive** a JSON request `{ "prompt": "..." }` +3. **Connect** to a running `copilot` server via TCP +4. **Open a session** targeting the `gpt-4.1` model +5. **Forward the prompt** and collect the response +6. **Return** a JSON response `{ "response": "..." }` + +## Languages + +| Directory | SDK / Approach | Language | HTTP Framework | +|-----------|---------------|----------|----------------| +| `typescript/` | `@github/copilot-sdk` | TypeScript (Node.js) | Express | +| `python/` | `github-copilot-sdk` | Python | Flask | +| `go/` | `github.com/github/copilot-sdk/go` | Go | net/http | + +## Prerequisites + +- **Copilot CLI** — set `COPILOT_CLI_PATH` +- **Authentication** — set `GITHUB_TOKEN`, or run `gh auth login` +- **Node.js 20+** (TypeScript sample) +- **Python 3.10+** (Python sample) +- **Go 1.24+** (Go sample) + +## Starting the Server + +Start `copilot` as a TCP server before running any sample: + +```bash +copilot --port 3000 --headless --auth-token-env GITHUB_TOKEN +``` + +## Quick Start + +**TypeScript** +```bash +cd typescript +npm install && npm run build +CLI_URL=localhost:3000 npm start +# In another terminal: +curl -X POST http://localhost:8080/chat \ + -H "Content-Type: application/json" \ + -d '{"prompt": "What is the capital of France?"}' +``` + +**Python** +```bash +cd python +pip install -r requirements.txt +CLI_URL=localhost:3000 python main.py +# In another terminal: +curl -X POST http://localhost:8080/chat \ + -H "Content-Type: application/json" \ + -d '{"prompt": "What is the capital of France?"}' +``` + +**Go** +```bash +cd go +CLI_URL=localhost:3000 go run main.go +# In another terminal: +curl -X POST http://localhost:8080/chat \ + -H "Content-Type: application/json" \ + -d '{"prompt": "What is the capital of France?"}' +``` + +All samples default to `localhost:3000` for the Copilot CLI and port `8080` for the HTTP server. Override with `CLI_URL` (or `COPILOT_CLI_URL`) and `PORT` environment variables: + +```bash +CLI_URL=localhost:4000 PORT=9090 npm start +``` + +## Verification + +A script is included that starts the server, builds, and end-to-end tests every sample: + +```bash +./verify.sh +``` + +It runs in three phases: + +1. **Server** — starts `copilot` on a random port +2. **Build** — installs dependencies and compiles each sample +3. **E2E Run** — starts each HTTP server, sends a `POST /chat` request via curl, and verifies it returns a response + +The server is automatically stopped when the script exits. diff --git a/test/scenarios/bundling/app-backend-to-server/csharp/Program.cs b/test/scenarios/bundling/app-backend-to-server/csharp/Program.cs new file mode 100644 index 000000000..df3a335b0 --- /dev/null +++ b/test/scenarios/bundling/app-backend-to-server/csharp/Program.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using GitHub.Copilot.SDK; + +var port = Environment.GetEnvironmentVariable("PORT") ?? "8080"; +var cliUrl = Environment.GetEnvironmentVariable("CLI_URL") + ?? Environment.GetEnvironmentVariable("COPILOT_CLI_URL") + ?? "localhost:3000"; + +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.UseUrls($"http://0.0.0.0:{port}"); +var app = builder.Build(); + +app.MapPost("/chat", async (HttpContext ctx) => +{ + var body = await JsonSerializer.DeserializeAsync(ctx.Request.Body); + var prompt = body.TryGetProperty("prompt", out var p) ? p.GetString() : null; + if (string.IsNullOrEmpty(prompt)) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "Missing 'prompt' in request body" }); + return; + } + + using var client = new CopilotClient(new CopilotClientOptions { CliUrl = cliUrl }); + await client.StartAsync(); + + try + { + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = prompt, + }); + + if (response?.Data?.Content != null) + { + await ctx.Response.WriteAsJsonAsync(new { response = response.Data.Content }); + } + else + { + ctx.Response.StatusCode = 502; + await ctx.Response.WriteAsJsonAsync(new { error = "No response content from Copilot CLI" }); + } + } + finally + { + await client.StopAsync(); + } +}); + +Console.WriteLine($"Listening on port {port}"); +app.Run(); diff --git a/test/scenarios/bundling/app-backend-to-server/csharp/csharp.csproj b/test/scenarios/bundling/app-backend-to-server/csharp/csharp.csproj new file mode 100644 index 000000000..b62a989b3 --- /dev/null +++ b/test/scenarios/bundling/app-backend-to-server/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/bundling/app-backend-to-server/go/go.mod b/test/scenarios/bundling/app-backend-to-server/go/go.mod new file mode 100644 index 000000000..6d01df73b --- /dev/null +++ b/test/scenarios/bundling/app-backend-to-server/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/bundling/app-backend-to-server/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/bundling/app-backend-to-server/go/go.sum b/test/scenarios/bundling/app-backend-to-server/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/bundling/app-backend-to-server/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/bundling/app-backend-to-server/go/main.go b/test/scenarios/bundling/app-backend-to-server/go/main.go new file mode 100644 index 000000000..afc8858f5 --- /dev/null +++ b/test/scenarios/bundling/app-backend-to-server/go/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "strings" + "time" + + copilot "github.com/github/copilot-sdk/go" +) + +func cliURL() string { + if u := os.Getenv("CLI_URL"); u != "" { + return u + } + if u := os.Getenv("COPILOT_CLI_URL"); u != "" { + return u + } + return "localhost:3000" +} + +type chatRequest struct { + Prompt string `json:"prompt"` +} + +type chatResponse struct { + Response string `json:"response,omitempty"` + Error string `json:"error,omitempty"` +} + +func chatHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + writeJSON(w, http.StatusBadRequest, chatResponse{Error: "Failed to read body"}) + return + } + + var req chatRequest + if err := json.Unmarshal(body, &req); err != nil || req.Prompt == "" { + writeJSON(w, http.StatusBadRequest, chatResponse{Error: "Missing 'prompt' in request body"}) + return + } + + client := copilot.NewClient(&copilot.ClientOptions{ + CLIUrl: cliURL(), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + writeJSON(w, http.StatusInternalServerError, chatResponse{Error: err.Error()}) + return + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, chatResponse{Error: err.Error()}) + return + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: req.Prompt, + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, chatResponse{Error: err.Error()}) + return + } + + if response != nil && response.Data.Content != nil { + writeJSON(w, http.StatusOK, chatResponse{Response: *response.Data.Content}) + } else { + writeJSON(w, http.StatusBadGateway, chatResponse{Error: "No response content from Copilot CLI"}) + } +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + mux := http.NewServeMux() + mux.HandleFunc("/chat", chatHandler) + + listener, err := net.Listen("tcp", ":"+port) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Listening on port %s\n", port) + + if os.Getenv("SELF_TEST") == "1" { + go func() { + http.Serve(listener, mux) + }() + + time.Sleep(500 * time.Millisecond) + url := fmt.Sprintf("http://localhost:%s/chat", port) + resp, err := http.Post(url, "application/json", + strings.NewReader(`{"prompt":"What is the capital of France?"}`)) + if err != nil { + log.Fatal("Self-test error:", err) + } + defer resp.Body.Close() + + var result chatResponse + json.NewDecoder(resp.Body).Decode(&result) + if result.Response != "" { + fmt.Println(result.Response) + } else { + log.Fatal("Self-test failed:", result.Error) + } + } else { + http.Serve(listener, mux) + } +} diff --git a/test/scenarios/bundling/app-backend-to-server/python/main.py b/test/scenarios/bundling/app-backend-to-server/python/main.py new file mode 100644 index 000000000..218505f4a --- /dev/null +++ b/test/scenarios/bundling/app-backend-to-server/python/main.py @@ -0,0 +1,75 @@ +import asyncio +import json +import os +import sys +import urllib.request + +from flask import Flask, request, jsonify +from copilot import CopilotClient + +app = Flask(__name__) + +CLI_URL = os.environ.get("CLI_URL", os.environ.get("COPILOT_CLI_URL", "localhost:3000")) + + +async def ask_copilot(prompt: str) -> str: + client = CopilotClient({"cli_url": CLI_URL}) + + try: + session = await client.create_session({"model": "claude-haiku-4.5"}) + + response = await session.send_and_wait({"prompt": prompt}) + + await session.destroy() + + if response: + return response.data.content + return "" + finally: + await client.stop() + + +@app.route("/chat", methods=["POST"]) +def chat(): + data = request.get_json(force=True) + prompt = data.get("prompt", "") + if not prompt: + return jsonify({"error": "Missing 'prompt' in request body"}), 400 + + content = asyncio.run(ask_copilot(prompt)) + if content: + return jsonify({"response": content}) + return jsonify({"error": "No response content from Copilot CLI"}), 502 + + +def self_test(port: int): + """Send a test request to ourselves and print the response.""" + url = f"http://localhost:{port}/chat" + payload = json.dumps({"prompt": "What is the capital of France?"}).encode() + req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req) as resp: + result = json.loads(resp.read().decode()) + if result.get("response"): + print(result["response"]) + else: + print("Self-test failed:", result, file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + import threading + + port = int(os.environ.get("PORT", "8080")) + + if os.environ.get("SELF_TEST") == "1": + # Start server in a background thread, run self-test, then exit + server_thread = threading.Thread( + target=lambda: app.run(host="0.0.0.0", port=port, debug=False), + daemon=True, + ) + server_thread.start() + import time + time.sleep(1) + self_test(port) + else: + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/test/scenarios/bundling/app-backend-to-server/python/requirements.txt b/test/scenarios/bundling/app-backend-to-server/python/requirements.txt new file mode 100644 index 000000000..c6b6d06c1 --- /dev/null +++ b/test/scenarios/bundling/app-backend-to-server/python/requirements.txt @@ -0,0 +1,2 @@ +flask +-e ../../../../../python diff --git a/test/scenarios/bundling/app-backend-to-server/typescript/package.json b/test/scenarios/bundling/app-backend-to-server/typescript/package.json new file mode 100644 index 000000000..eca6e68ce --- /dev/null +++ b/test/scenarios/bundling/app-backend-to-server/typescript/package.json @@ -0,0 +1,21 @@ +{ + "name": "bundling-app-backend-to-server-typescript", + "version": "1.0.0", + "private": true, + "description": "App-backend-to-server Copilot SDK sample — web backend proxies to Copilot CLI TCP server", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs", + "express": "^4.21.0" + }, + "devDependencies": { + "@types/express": "^4.17.0", + "@types/node": "^20.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.5.0" + } +} diff --git a/test/scenarios/bundling/app-backend-to-server/typescript/src/index.ts b/test/scenarios/bundling/app-backend-to-server/typescript/src/index.ts new file mode 100644 index 000000000..3394c0d3a --- /dev/null +++ b/test/scenarios/bundling/app-backend-to-server/typescript/src/index.ts @@ -0,0 +1,64 @@ +import express from "express"; +import { CopilotClient } from "@github/copilot-sdk"; + +const PORT = parseInt(process.env.PORT || "8080", 10); +const CLI_URL = process.env.CLI_URL || process.env.COPILOT_CLI_URL || "localhost:3000"; + +const app = express(); +app.use(express.json()); + +app.post("/chat", async (req, res) => { + const { prompt } = req.body; + if (!prompt || typeof prompt !== "string") { + res.status(400).json({ error: "Missing 'prompt' in request body" }); + return; + } + + const client = new CopilotClient({ cliUrl: CLI_URL }); + + try { + const session = await client.createSession({ model: "claude-haiku-4.5" }); + + const response = await session.sendAndWait({ prompt }); + + await session.destroy(); + + if (response?.data.content) { + res.json({ response: response.data.content }); + } else { + res.status(502).json({ error: "No response content from Copilot CLI" }); + } + } catch (err) { + res.status(500).json({ error: String(err) }); + } finally { + await client.stop(); + } +}); + +// When run directly, start server and optionally self-test +const server = app.listen(PORT, async () => { + console.log(`Listening on port ${PORT}`); + + // Self-test mode: send a request and exit + if (process.env.SELF_TEST === "1") { + try { + const resp = await fetch(`http://localhost:${PORT}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: "What is the capital of France?" }), + }); + const data = await resp.json(); + if (data.response) { + console.log(data.response); + } else { + console.error("Self-test failed:", data); + process.exit(1); + } + } catch (err) { + console.error("Self-test error:", err); + process.exit(1); + } finally { + server.close(); + } + } +}); diff --git a/test/scenarios/bundling/app-backend-to-server/verify.sh b/test/scenarios/bundling/app-backend-to-server/verify.sh new file mode 100755 index 000000000..812a2cda4 --- /dev/null +++ b/test/scenarios/bundling/app-backend-to-server/verify.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 +SERVER_PID="" +SERVER_PORT_FILE="" +APP_PID="" + +cleanup() { + if [ -n "${APP_PID:-}" ] && kill -0 "$APP_PID" 2>/dev/null; then + kill "$APP_PID" 2>/dev/null || true + wait "$APP_PID" 2>/dev/null || true + fi + if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then + echo "" + echo "Stopping Copilot CLI server (PID $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + [ -n "$SERVER_PORT_FILE" ] && rm -f "$SERVER_PORT_FILE" +} +trap cleanup EXIT + +# Resolve Copilot CLI binary: use COPILOT_CLI_PATH env var or find the SDK bundled CLI. +if [ -z "${COPILOT_CLI_PATH:-}" ]; then + # Try to resolve from the TypeScript sample node_modules + TS_DIR="$SCRIPT_DIR/typescript" + if [ -d "$TS_DIR/node_modules/@github/copilot" ]; then + COPILOT_CLI_PATH="$(node -e "console.log(require.resolve('@github/copilot'))" 2>/dev/null || true)" + fi + # Fallback: check PATH + if [ -z "${COPILOT_CLI_PATH:-}" ]; then + COPILOT_CLI_PATH="$(command -v copilot 2>/dev/null || true)" + fi +fi +if [ -z "${COPILOT_CLI_PATH:-}" ]; then + echo "❌ Could not find Copilot CLI binary." + echo " Set COPILOT_CLI_PATH or run: cd typescript && npm install" + exit 1 +fi +echo "Using CLI: $COPILOT_CLI_PATH" + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + echo "$output" + echo "✅ $name passed (got response)" + PASS=$((PASS + 1)) + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +# Helper: start an HTTP server, curl it, stop it +run_http_test() { + local name="$1" + local start_cmd="$2" + local app_port="$3" + local max_retries="${4:-15}" + + printf "━━━ %s ━━━\n" "$name" + + # Start the HTTP server in the background + eval "$start_cmd" & + APP_PID=$! + + # Wait for server to be ready + local ready=false + for i in $(seq 1 "$max_retries"); do + if curl -sf "http://localhost:${app_port}/chat" -X POST \ + -H "Content-Type: application/json" \ + -d '{"prompt":"ping"}' >/dev/null 2>&1; then + ready=true + break + fi + if ! kill -0 "$APP_PID" 2>/dev/null; then + break + fi + sleep 1 + done + + if [ "$ready" = false ]; then + echo "Server did not become ready" + echo "❌ $name failed (server not ready)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (server not ready)" + kill "$APP_PID" 2>/dev/null || true + wait "$APP_PID" 2>/dev/null || true + APP_PID="" + echo "" + return + fi + + # Send the real test request with timeout + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" curl -sf "http://localhost:${app_port}/chat" \ + -X POST -H "Content-Type: application/json" \ + -d '{"prompt":"What is the capital of France?"}' 2>&1) && code=0 || code=$? + else + output=$(curl -sf "http://localhost:${app_port}/chat" \ + -X POST -H "Content-Type: application/json" \ + -d '{"prompt":"What is the capital of France?"}' 2>&1) && code=0 || code=$? + fi + + # Stop the HTTP server + kill "$APP_PID" 2>/dev/null || true + wait "$APP_PID" 2>/dev/null || true + APP_PID="" + + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + echo "$output" + if echo "$output" | grep -qi 'Paris\|capital\|France'; then + echo "✅ $name passed (got response with expected content)" + PASS=$((PASS + 1)) + else + echo "❌ $name failed (response missing expected content)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (no expected content)" + fi + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +# Kill any stale processes on the test ports from previous interrupted runs +for test_port in 18081 18082 18083 18084; do + stale_pid=$(lsof -ti ":$test_port" 2>/dev/null || true) + if [ -n "$stale_pid" ]; then + echo "Killing stale process on port $test_port (PID $stale_pid)" + kill $stale_pid 2>/dev/null || true + fi +done + +echo "══════════════════════════════════════" +echo " Starting Copilot CLI TCP server" +echo "══════════════════════════════════════" +echo "" + +SERVER_PORT_FILE=$(mktemp) +"$COPILOT_CLI_PATH" --headless --auth-token-env GITHUB_TOKEN > "$SERVER_PORT_FILE" 2>&1 & +SERVER_PID=$! + +# Wait for server to announce its port +echo "Waiting for server to be ready..." +PORT="" +for i in $(seq 1 30); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "❌ Server process exited unexpectedly" + cat "$SERVER_PORT_FILE" 2>/dev/null + exit 1 + fi + PORT=$(grep -o 'listening on port [0-9]*' "$SERVER_PORT_FILE" 2>/dev/null | grep -o '[0-9]*' || true) + if [ -n "$PORT" ]; then + break + fi + if [ "$i" -eq 30 ]; then + echo "❌ Server did not announce port within 30 seconds" + exit 1 + fi + sleep 1 +done +export COPILOT_CLI_URL="localhost:$PORT" +echo "Server is ready on port $PORT (PID $SERVER_PID)" +echo "" + +echo "══════════════════════════════════════" +echo " Verifying app-backend-to-server samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o app-backend-to-server-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: start server, curl, stop +run_http_test "TypeScript (run)" \ + "cd '$SCRIPT_DIR/typescript' && PORT=18081 CLI_URL=$COPILOT_CLI_URL node dist/index.js" \ + 18081 + +# Python: start server, curl, stop +run_http_test "Python (run)" \ + "cd '$SCRIPT_DIR/python' && PORT=18082 CLI_URL=$COPILOT_CLI_URL python3 main.py" \ + 18082 + +# Go: start server, curl, stop +run_http_test "Go (run)" \ + "cd '$SCRIPT_DIR/go' && PORT=18083 CLI_URL=$COPILOT_CLI_URL ./app-backend-to-server-go" \ + 18083 + +# C#: start server, curl, stop (extra retries for JIT startup) +run_http_test "C# (run)" \ + "cd '$SCRIPT_DIR/csharp' && PORT=18084 COPILOT_CLI_URL=$COPILOT_CLI_URL dotnet run --no-build" \ + 18084 \ + 30 + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/bundling/app-direct-server/README.md b/test/scenarios/bundling/app-direct-server/README.md new file mode 100644 index 000000000..1b396dced --- /dev/null +++ b/test/scenarios/bundling/app-direct-server/README.md @@ -0,0 +1,84 @@ +# App-Direct-Server Samples + +Samples that demonstrate the **app-direct-server** deployment architecture of the Copilot SDK. In this scenario the SDK connects to a **pre-running** `copilot` TCP server — the app does not spawn or manage the server process. + +``` +┌─────────────┐ TCP (JSON-RPC) ┌──────────────┐ +│ Your App │ ─────────────────▶ │ Copilot CLI │ +│ (SDK) │ ◀───────────────── │ (TCP server) │ +└─────────────┘ └──────────────┘ +``` + +Each sample follows the same flow: + +1. **Connect** to a running `copilot` server via TCP +2. **Open a session** targeting the `gpt-4.1` model +3. **Send a prompt** ("What is the capital of France?") +4. **Print the response** and clean up + +## Languages + +| Directory | SDK / Approach | Language | +|-----------|---------------|----------| +| `typescript/` | `@github/copilot-sdk` | TypeScript (Node.js) | +| `python/` | `github-copilot-sdk` | Python | +| `go/` | `github.com/github/copilot-sdk/go` | Go | + +## Prerequisites + +- **Copilot CLI** — set `COPILOT_CLI_PATH` +- **Authentication** — set `GITHUB_TOKEN`, or run `gh auth login` +- **Node.js 20+** (TypeScript sample) +- **Python 3.10+** (Python sample) +- **Go 1.24+** (Go sample) + +## Starting the Server + +Start `copilot` as a TCP server before running any sample: + +```bash +copilot --port 3000 --headless --auth-token-env GITHUB_TOKEN +``` + +## Quick Start + +**TypeScript** +```bash +cd typescript +npm install && npm run build && npm start +``` + +**Python** +```bash +cd python +pip install -r requirements.txt +python main.py +``` + +**Go** +```bash +cd go +go run main.go +``` + +All samples default to `localhost:3000`. Override with the `COPILOT_CLI_URL` environment variable: + +```bash +COPILOT_CLI_URL=localhost:8080 npm start +``` + +## Verification + +A script is included that starts the server, builds, and end-to-end tests every sample: + +```bash +./verify.sh +``` + +It runs in three phases: + +1. **Server** — starts `copilot` on a random port (auto-detected from server output) +2. **Build** — installs dependencies and compiles each sample +3. **E2E Run** — executes each sample with a 60-second timeout and verifies it produces output + +The server is automatically stopped when the script exits. diff --git a/test/scenarios/bundling/app-direct-server/csharp/Program.cs b/test/scenarios/bundling/app-direct-server/csharp/Program.cs new file mode 100644 index 000000000..6dd14e9db --- /dev/null +++ b/test/scenarios/bundling/app-direct-server/csharp/Program.cs @@ -0,0 +1,33 @@ +using GitHub.Copilot.SDK; + +var cliUrl = Environment.GetEnvironmentVariable("COPILOT_CLI_URL") ?? "localhost:3000"; + +using var client = new CopilotClient(new CopilotClientOptions { CliUrl = cliUrl }); +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response?.Data?.Content != null) + { + Console.WriteLine(response.Data.Content); + } + else + { + Console.Error.WriteLine("No response content received"); + Environment.Exit(1); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/bundling/app-direct-server/csharp/csharp.csproj b/test/scenarios/bundling/app-direct-server/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/bundling/app-direct-server/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/bundling/app-direct-server/go/go.mod b/test/scenarios/bundling/app-direct-server/go/go.mod new file mode 100644 index 000000000..db24ae393 --- /dev/null +++ b/test/scenarios/bundling/app-direct-server/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/bundling/app-direct-server/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/bundling/app-direct-server/go/go.sum b/test/scenarios/bundling/app-direct-server/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/bundling/app-direct-server/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/bundling/app-direct-server/go/main.go b/test/scenarios/bundling/app-direct-server/go/main.go new file mode 100644 index 000000000..9a0b1be4e --- /dev/null +++ b/test/scenarios/bundling/app-direct-server/go/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + cliUrl := os.Getenv("COPILOT_CLI_URL") + if cliUrl == "" { + cliUrl = "localhost:3000" + } + + client := copilot.NewClient(&copilot.ClientOptions{ + CLIUrl: cliUrl, + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/bundling/app-direct-server/python/main.py b/test/scenarios/bundling/app-direct-server/python/main.py new file mode 100644 index 000000000..05aaa9270 --- /dev/null +++ b/test/scenarios/bundling/app-direct-server/python/main.py @@ -0,0 +1,26 @@ +import asyncio +import os +from copilot import CopilotClient + + +async def main(): + client = CopilotClient({ + "cli_url": os.environ.get("COPILOT_CLI_URL", "localhost:3000"), + }) + + try: + session = await client.create_session({"model": "claude-haiku-4.5"}) + + response = await session.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/bundling/app-direct-server/python/requirements.txt b/test/scenarios/bundling/app-direct-server/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/bundling/app-direct-server/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/bundling/app-direct-server/typescript/package.json b/test/scenarios/bundling/app-direct-server/typescript/package.json new file mode 100644 index 000000000..5ceb5c16f --- /dev/null +++ b/test/scenarios/bundling/app-direct-server/typescript/package.json @@ -0,0 +1,19 @@ +{ + "name": "bundling-app-direct-server-typescript", + "version": "1.0.0", + "private": true, + "description": "App-direct-server Copilot SDK sample — connects to a running Copilot CLI TCP server", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.5.0" + } +} diff --git a/test/scenarios/bundling/app-direct-server/typescript/src/index.ts b/test/scenarios/bundling/app-direct-server/typescript/src/index.ts new file mode 100644 index 000000000..139e47a86 --- /dev/null +++ b/test/scenarios/bundling/app-direct-server/typescript/src/index.ts @@ -0,0 +1,31 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + cliUrl: process.env.COPILOT_CLI_URL || "localhost:3000", + }); + + try { + const session = await client.createSession({ model: "claude-haiku-4.5" }); + + const response = await session.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response?.data.content) { + console.log(response.data.content); + } else { + console.error("No response content received"); + process.exit(1); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/bundling/app-direct-server/typescript/tsconfig.json b/test/scenarios/bundling/app-direct-server/typescript/tsconfig.json new file mode 100644 index 000000000..8e7a1798c --- /dev/null +++ b/test/scenarios/bundling/app-direct-server/typescript/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/test/scenarios/bundling/app-direct-server/verify.sh b/test/scenarios/bundling/app-direct-server/verify.sh new file mode 100755 index 000000000..6a4bbcc39 --- /dev/null +++ b/test/scenarios/bundling/app-direct-server/verify.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 +SERVER_PID="" +SERVER_PORT_FILE="" + +cleanup() { + if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then + echo "" + echo "Stopping Copilot CLI server (PID $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + [ -n "$SERVER_PORT_FILE" ] && rm -f "$SERVER_PORT_FILE" +} +trap cleanup EXIT + +# Resolve Copilot CLI binary: use COPILOT_CLI_PATH env var or find the SDK bundled CLI. +if [ -z "${COPILOT_CLI_PATH:-}" ]; then + # Try to resolve from the TypeScript sample node_modules + TS_DIR="$SCRIPT_DIR/typescript" + if [ -d "$TS_DIR/node_modules/@github/copilot" ]; then + COPILOT_CLI_PATH="$(node -e "console.log(require.resolve('@github/copilot'))" 2>/dev/null || true)" + fi + # Fallback: check PATH + if [ -z "${COPILOT_CLI_PATH:-}" ]; then + COPILOT_CLI_PATH="$(command -v copilot 2>/dev/null || true)" + fi +fi +if [ -z "${COPILOT_CLI_PATH:-}" ]; then + echo "❌ Could not find Copilot CLI binary." + echo " Set COPILOT_CLI_PATH or run: cd typescript && npm install" + exit 1 +fi +echo "Using CLI: $COPILOT_CLI_PATH" + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + echo "$output" + echo "✅ $name passed (got response)" + PASS=$((PASS + 1)) + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Starting Copilot CLI TCP server" +echo "══════════════════════════════════════" +echo "" + +SERVER_PORT_FILE=$(mktemp) +"$COPILOT_CLI_PATH" --headless --auth-token-env GITHUB_TOKEN > "$SERVER_PORT_FILE" 2>&1 & +SERVER_PID=$! + +# Wait for server to announce its port +echo "Waiting for server to be ready..." +PORT="" +for i in $(seq 1 30); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "❌ Server process exited unexpectedly" + cat "$SERVER_PORT_FILE" 2>/dev/null + exit 1 + fi + PORT=$(grep -o 'listening on port [0-9]*' "$SERVER_PORT_FILE" 2>/dev/null | grep -o '[0-9]*' || true) + if [ -n "$PORT" ]; then + break + fi + if [ "$i" -eq 30 ]; then + echo "❌ Server did not announce port within 30 seconds" + exit 1 + fi + sleep 1 +done +export COPILOT_CLI_URL="localhost:$PORT" +echo "Server is ready on port $PORT (PID $SERVER_PID)" +echo "" + +echo "══════════════════════════════════════" +echo " Verifying app-direct-server samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o app-direct-server-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c " + cd '$SCRIPT_DIR/typescript' && \ + output=\$(node dist/index.js 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response' +" + +# Python: run +run_with_timeout "Python (run)" bash -c " + cd '$SCRIPT_DIR/python' && \ + output=\$(python3 main.py 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response' +" + +# Go: run +run_with_timeout "Go (run)" bash -c " + cd '$SCRIPT_DIR/go' && \ + output=\$(./app-direct-server-go 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response' +" + +# C#: run +run_with_timeout "C# (run)" bash -c " + cd '$SCRIPT_DIR/csharp' && \ + output=\$(COPILOT_CLI_URL=$COPILOT_CLI_URL dotnet run --no-build 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response' +" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/bundling/container-proxy/.dockerignore b/test/scenarios/bundling/container-proxy/.dockerignore new file mode 100644 index 000000000..df91b0e65 --- /dev/null +++ b/test/scenarios/bundling/container-proxy/.dockerignore @@ -0,0 +1,3 @@ +* +!experimental-copilot-server/ +experimental-copilot-server/target/ diff --git a/test/scenarios/bundling/container-proxy/Dockerfile b/test/scenarios/bundling/container-proxy/Dockerfile new file mode 100644 index 000000000..bf7c86f0a --- /dev/null +++ b/test/scenarios/bundling/container-proxy/Dockerfile @@ -0,0 +1,19 @@ +# syntax=docker/dockerfile:1 + +# Runtime image for Copilot CLI +# The final image contains ONLY the binary — no source code, no credentials. +# Requires a pre-built Copilot CLI binary to be copied in. + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* + +# Copy a pre-built Copilot CLI binary +# Set COPILOT_CLI_PATH build arg or provide the binary at build context root +ARG COPILOT_CLI_PATH=copilot +COPY ${COPILOT_CLI_PATH} /usr/local/bin/copilot +RUN chmod +x /usr/local/bin/copilot + +EXPOSE 3000 + +ENTRYPOINT ["copilot", "--headless", "--port", "3000", "--bind", "0.0.0.0", "--auth-token-env", "GITHUB_TOKEN"] diff --git a/test/scenarios/bundling/container-proxy/README.md b/test/scenarios/bundling/container-proxy/README.md new file mode 100644 index 000000000..25545d754 --- /dev/null +++ b/test/scenarios/bundling/container-proxy/README.md @@ -0,0 +1,108 @@ +# Container-Proxy Samples + +Run the Copilot CLI inside a Docker container with a simple proxy on the host that returns canned responses. This demonstrates the deployment pattern where an external service intercepts the agent's LLM calls — in production the proxy would add credentials and forward to a real provider; here it just returns a fixed reply as proof-of-concept. + +``` + Host Machine +┌──────────────────────────────────────────────────────┐ +│ │ +│ ┌─────────────┐ │ +│ │ Your App │ TCP :3000 │ +│ │ (SDK) │ ────────────────┐ │ +│ └─────────────┘ │ │ +│ ▼ │ +│ ┌──────────────────────────┐ │ +│ │ Docker Container │ │ +│ │ Copilot CLI │ │ +│ │ --port 3000 --headless │ │ +│ │ --bind 0.0.0.0 │ │ +│ │ --auth-token-env │ │ +│ └────────────┬─────────────┘ │ +│ │ │ +│ HTTP to host.docker.internal:4000 │ +│ │ │ +│ ┌───────────▼──────────────┐ │ +│ │ proxy.py │ │ +│ │ (port 4000) │ │ +│ │ Returns canned response │ │ +│ └─────────────────────────-┘ │ +│ │ +└──────────────────────────────────────────────────────┘ +``` + +## Why This Pattern? + +The agent runtime (Copilot CLI) has **no access to API keys**. All LLM traffic flows through a proxy on the host. In production you would replace `proxy.py` with a real proxy that injects credentials and forwards to OpenAI/Anthropic/etc. This means: + +- **No secrets in the image** — safe to share, scan, deploy anywhere +- **No secrets at runtime** — even if the container is compromised, there are no tokens to steal +- **Swap providers freely** — change the proxy target without rebuilding the container +- **Centralized key management** — one proxy manages keys for all your agents/services + +## Prerequisites + +- **Docker** with Docker Compose +- **Python 3** (for the proxy — uses only stdlib, no pip install needed) + +## Setup + +### 1. Start the proxy + +```bash +python3 proxy.py 4000 +``` + +This starts a minimal OpenAI-compatible HTTP server on port 4000 that returns a canned "The capital of France is Paris." response for every request. + +### 2. Start the Copilot CLI in Docker + +```bash +docker compose up -d --build +``` + +This builds the Copilot CLI from source and starts it on port 3000. It sends LLM requests to `host.docker.internal:4000` — no API keys are passed into the container. + +### 3. Run a client sample + +**TypeScript** +```bash +cd typescript && npm install && npm run build && npm start +``` + +**Python** +```bash +cd python && pip install -r requirements.txt && python main.py +``` + +**Go** +```bash +cd go && go run main.go +``` + +All samples connect to `localhost:3000` by default. Override with `COPILOT_CLI_URL`. + +## Verification + +Run all samples end-to-end: + +```bash +chmod +x verify.sh +./verify.sh +``` + +## Languages + +| Directory | SDK / Approach | Language | +|-----------|---------------|----------| +| `typescript/` | `@github/copilot-sdk` | TypeScript (Node.js) | +| `python/` | `github-copilot-sdk` | Python | +| `go/` | `github.com/github/copilot-sdk/go` | Go | + +## How It Works + +1. **Copilot CLI** starts in Docker with `COPILOT_API_URL=http://host.docker.internal:4000` — this overrides the default Copilot API endpoint to point at the proxy +2. When the agent needs to call an LLM, it sends a standard OpenAI-format request to the proxy +3. **proxy.py** receives the request and returns a canned response (in production, this would inject credentials and forward to a real provider) +4. The response flows back: proxy → Copilot CLI → your app + +The container never sees or needs any API credentials. diff --git a/test/scenarios/bundling/container-proxy/csharp/Program.cs b/test/scenarios/bundling/container-proxy/csharp/Program.cs new file mode 100644 index 000000000..6dd14e9db --- /dev/null +++ b/test/scenarios/bundling/container-proxy/csharp/Program.cs @@ -0,0 +1,33 @@ +using GitHub.Copilot.SDK; + +var cliUrl = Environment.GetEnvironmentVariable("COPILOT_CLI_URL") ?? "localhost:3000"; + +using var client = new CopilotClient(new CopilotClientOptions { CliUrl = cliUrl }); +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response?.Data?.Content != null) + { + Console.WriteLine(response.Data.Content); + } + else + { + Console.Error.WriteLine("No response content received"); + Environment.Exit(1); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/bundling/container-proxy/csharp/csharp.csproj b/test/scenarios/bundling/container-proxy/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/bundling/container-proxy/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/bundling/container-proxy/docker-compose.yml b/test/scenarios/bundling/container-proxy/docker-compose.yml new file mode 100644 index 000000000..fe2291031 --- /dev/null +++ b/test/scenarios/bundling/container-proxy/docker-compose.yml @@ -0,0 +1,24 @@ +# Container-proxy sample: Copilot CLI in Docker, simple proxy on host. +# +# The proxy (proxy.py) runs on the host and returns canned responses. +# This demonstrates the network path without needing real LLM credentials. +# +# Usage: +# 1. Start the proxy on the host: python3 proxy.py 4000 +# 2. Start the container: docker compose up -d +# 3. Run client samples against localhost:3000 + +services: + copilot-cli: + build: + context: ../../../.. + dockerfile: test/scenarios/bundling/container-proxy/Dockerfile + ports: + - "3000:3000" + environment: + # Point LLM requests at the host proxy — returns canned responses + COPILOT_API_URL: "http://host.docker.internal:4000" + # Dummy token so Copilot CLI enters the Token auth path + GITHUB_TOKEN: "not-used" + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/test/scenarios/bundling/container-proxy/go/go.mod b/test/scenarios/bundling/container-proxy/go/go.mod new file mode 100644 index 000000000..086f43175 --- /dev/null +++ b/test/scenarios/bundling/container-proxy/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/bundling/container-proxy/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/bundling/container-proxy/go/go.sum b/test/scenarios/bundling/container-proxy/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/bundling/container-proxy/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/bundling/container-proxy/go/main.go b/test/scenarios/bundling/container-proxy/go/main.go new file mode 100644 index 000000000..9a0b1be4e --- /dev/null +++ b/test/scenarios/bundling/container-proxy/go/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + cliUrl := os.Getenv("COPILOT_CLI_URL") + if cliUrl == "" { + cliUrl = "localhost:3000" + } + + client := copilot.NewClient(&copilot.ClientOptions{ + CLIUrl: cliUrl, + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/bundling/container-proxy/proxy.py b/test/scenarios/bundling/container-proxy/proxy.py new file mode 100644 index 000000000..afe999a4c --- /dev/null +++ b/test/scenarios/bundling/container-proxy/proxy.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Minimal OpenAI-compatible proxy for the container-proxy sample. + +This replaces a real LLM provider — Copilot CLI (running in Docker) sends +its model requests here and gets back a canned response. The point is to +prove the network path: + + client → Copilot CLI (container :3000) → this proxy (host :4000) +""" + +import json +import sys +import time +from http.server import HTTPServer, BaseHTTPRequestHandler + + +class ProxyHandler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) if length else {} + + model = body.get("model", "claude-haiku-4.5") + stream = body.get("stream", False) + + if stream: + self._handle_stream(model) + else: + self._handle_non_stream(model) + + def do_GET(self): + # Health check + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"status": "ok"}).encode()) + + # ── Non-streaming ──────────────────────────────────────────────── + + def _handle_non_stream(self, model: str): + resp = { + "id": "chatcmpl-proxy-0001", + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The capital of France is Paris.", + }, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + } + payload = json.dumps(resp).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + # ── Streaming (SSE) ────────────────────────────────────────────── + + def _handle_stream(self, model: str): + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + + ts = int(time.time()) + + # Single content chunk + chunk = { + "id": "chatcmpl-proxy-0001", + "object": "chat.completion.chunk", + "created": ts, + "model": model, + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "The capital of France is Paris."}, + "finish_reason": None, + } + ], + } + self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode()) + self.wfile.flush() + + # Final chunk with finish_reason + done_chunk = { + "id": "chatcmpl-proxy-0001", + "object": "chat.completion.chunk", + "created": ts, + "model": model, + "choices": [ + { + "index": 0, + "delta": {}, + "finish_reason": "stop", + } + ], + } + self.wfile.write(f"data: {json.dumps(done_chunk)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + def log_message(self, format, *args): + print(f"[proxy] {args[0]}", file=sys.stderr) + + +def main(): + port = int(sys.argv[1]) if len(sys.argv) > 1 else 4000 + server = HTTPServer(("0.0.0.0", port), ProxyHandler) + print(f"Proxy listening on :{port}", flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/test/scenarios/bundling/container-proxy/python/main.py b/test/scenarios/bundling/container-proxy/python/main.py new file mode 100644 index 000000000..05aaa9270 --- /dev/null +++ b/test/scenarios/bundling/container-proxy/python/main.py @@ -0,0 +1,26 @@ +import asyncio +import os +from copilot import CopilotClient + + +async def main(): + client = CopilotClient({ + "cli_url": os.environ.get("COPILOT_CLI_URL", "localhost:3000"), + }) + + try: + session = await client.create_session({"model": "claude-haiku-4.5"}) + + response = await session.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/bundling/container-proxy/python/requirements.txt b/test/scenarios/bundling/container-proxy/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/bundling/container-proxy/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/bundling/container-proxy/typescript/package.json b/test/scenarios/bundling/container-proxy/typescript/package.json new file mode 100644 index 000000000..31b6d1ed0 --- /dev/null +++ b/test/scenarios/bundling/container-proxy/typescript/package.json @@ -0,0 +1,19 @@ +{ + "name": "bundling-container-proxy-typescript", + "version": "1.0.0", + "private": true, + "description": "Container-proxy Copilot SDK sample — connects to Copilot CLI running in Docker", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.5.0" + } +} diff --git a/test/scenarios/bundling/container-proxy/typescript/src/index.ts b/test/scenarios/bundling/container-proxy/typescript/src/index.ts new file mode 100644 index 000000000..139e47a86 --- /dev/null +++ b/test/scenarios/bundling/container-proxy/typescript/src/index.ts @@ -0,0 +1,31 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + cliUrl: process.env.COPILOT_CLI_URL || "localhost:3000", + }); + + try { + const session = await client.createSession({ model: "claude-haiku-4.5" }); + + const response = await session.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response?.data.content) { + console.log(response.data.content); + } else { + console.error("No response content received"); + process.exit(1); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/bundling/container-proxy/typescript/tsconfig.json b/test/scenarios/bundling/container-proxy/typescript/tsconfig.json new file mode 100644 index 000000000..8e7a1798c --- /dev/null +++ b/test/scenarios/bundling/container-proxy/typescript/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/test/scenarios/bundling/container-proxy/verify.sh b/test/scenarios/bundling/container-proxy/verify.sh new file mode 100755 index 000000000..f47fa2ad9 --- /dev/null +++ b/test/scenarios/bundling/container-proxy/verify.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# Skip if runtime source not available (needed for Docker build) +if [ ! -d "$ROOT_DIR/runtime" ]; then + echo "SKIP: runtime/ directory not found — cannot build Copilot CLI Docker image" + exit 0 +fi + +cleanup() { + echo "" + if [ -n "${PROXY_PID:-}" ] && kill -0 "$PROXY_PID" 2>/dev/null; then + echo "Stopping proxy (PID $PROXY_PID)..." + kill "$PROXY_PID" 2>/dev/null || true + fi + echo "Stopping Docker container..." + docker compose -f "$SCRIPT_DIR/docker-compose.yml" down --timeout 5 2>/dev/null || true +} +trap cleanup EXIT + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + echo "$output" + echo "✅ $name passed (got response)" + PASS=$((PASS + 1)) + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +# Kill any stale processes on test ports from previous interrupted runs +for test_port in 3000 4000; do + stale_pid=$(lsof -ti ":$test_port" 2>/dev/null || true) + if [ -n "$stale_pid" ]; then + echo "Cleaning up stale process on port $test_port (PID $stale_pid)" + kill $stale_pid 2>/dev/null || true + fi +done +docker compose -f "$SCRIPT_DIR/docker-compose.yml" down --timeout 5 2>/dev/null || true + +# ── Start the simple proxy ─────────────────────────────────────────── +PROXY_PORT=4000 +PROXY_PID="" + +echo "══════════════════════════════════════" +echo " Starting proxy on port $PROXY_PORT" +echo "══════════════════════════════════════" +echo "" + +python3 "$SCRIPT_DIR/proxy.py" "$PROXY_PORT" & +PROXY_PID=$! +sleep 1 + +if kill -0 "$PROXY_PID" 2>/dev/null; then + echo "✅ Proxy running (PID $PROXY_PID)" +else + echo "❌ Proxy failed to start" + exit 1 +fi +echo "" + +# ── Build and start container ──────────────────────────────────────── +echo "══════════════════════════════════════" +echo " Building and starting Copilot CLI container" +echo "══════════════════════════════════════" +echo "" + +docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d --build + +# Wait for Copilot CLI to be ready +echo "Waiting for Copilot CLI to be ready..." +for i in $(seq 1 30); do + if (echo > /dev/tcp/localhost/3000) 2>/dev/null; then + echo "✅ Copilot CLI is ready on port 3000" + break + fi + if [ "$i" -eq 30 ]; then + echo "❌ Copilot CLI did not become ready within 30 seconds" + docker compose -f "$SCRIPT_DIR/docker-compose.yml" logs + exit 1 + fi + sleep 1 +done +echo "" + +export COPILOT_CLI_URL="localhost:3000" + +echo "══════════════════════════════════════" +echo " Phase 1: Build client samples" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o container-proxy-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c " + cd '$SCRIPT_DIR/typescript' && \ + output=\$(node dist/index.js 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital' +" + +# Python: run +run_with_timeout "Python (run)" bash -c " + cd '$SCRIPT_DIR/python' && \ + output=\$(python3 main.py 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital' +" + +# Go: run +run_with_timeout "Go (run)" bash -c " + cd '$SCRIPT_DIR/go' && \ + output=\$(./container-proxy-go 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital' +" + +# C#: run +run_with_timeout "C# (run)" bash -c " + cd '$SCRIPT_DIR/csharp' && \ + output=\$(COPILOT_CLI_URL=$COPILOT_CLI_URL dotnet run --no-build 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital' +" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/bundling/fully-bundled/README.md b/test/scenarios/bundling/fully-bundled/README.md new file mode 100644 index 000000000..6d99e0d85 --- /dev/null +++ b/test/scenarios/bundling/fully-bundled/README.md @@ -0,0 +1,69 @@ +# Fully-Bundled Samples + +Self-contained samples that demonstrate the **fully-bundled** deployment architecture of the Copilot SDK. In this scenario the SDK spawns `copilot` as a child process over stdio — no external server or container is required. + +Each sample follows the same flow: + +1. **Create a client** that spawns `copilot` automatically +2. **Open a session** targeting the `gpt-4.1` model +3. **Send a prompt** ("What is the capital of France?") +4. **Print the response** and clean up + +## Languages + +| Directory | SDK / Approach | Language | +|-----------|---------------|----------| +| `typescript/` | `@github/copilot-sdk` | TypeScript (Node.js) | +| `typescript-wasm/` | `@github/copilot-sdk` with WASM runtime | TypeScript (Node.js) | +| `python/` | `github-copilot-sdk` | Python | +| `go/` | `github.com/github/copilot-sdk/go` | Go | + +## Prerequisites + +- **Copilot CLI** — set `COPILOT_CLI_PATH` +- **Authentication** — set `GITHUB_TOKEN`, or run `gh auth login` +- **Node.js 20+** (TypeScript samples) +- **Python 3.10+** (Python sample) +- **Go 1.24+** (Go sample) + +## Quick Start + +**TypeScript** +```bash +cd typescript +npm install && npm run build && npm start +``` + +**TypeScript (WASM)** +```bash +cd typescript-wasm +npm install && npm run build && npm start +``` + +**Python** +```bash +cd python +pip install -r requirements.txt +python main.py +``` + +**Go** +```bash +cd go +go run main.go +``` + +## Verification + +A script is included to build and end-to-end test every sample: + +```bash +./verify.sh +``` + +It runs in two phases: + +1. **Build** — installs dependencies and compiles each sample +2. **E2E Run** — executes each sample with a 60-second timeout and verifies it produces output + +Set `COPILOT_CLI_PATH` to point at your `copilot` binary if it isn't in the default location. diff --git a/test/scenarios/bundling/fully-bundled/csharp/Program.cs b/test/scenarios/bundling/fully-bundled/csharp/Program.cs new file mode 100644 index 000000000..50505b776 --- /dev/null +++ b/test/scenarios/bundling/fully-bundled/csharp/Program.cs @@ -0,0 +1,31 @@ +using GitHub.Copilot.SDK; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/bundling/fully-bundled/csharp/csharp.csproj b/test/scenarios/bundling/fully-bundled/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/bundling/fully-bundled/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/bundling/fully-bundled/go/go.mod b/test/scenarios/bundling/fully-bundled/go/go.mod new file mode 100644 index 000000000..93af1915a --- /dev/null +++ b/test/scenarios/bundling/fully-bundled/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/bundling/fully-bundled/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/bundling/fully-bundled/go/go.sum b/test/scenarios/bundling/fully-bundled/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/bundling/fully-bundled/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/bundling/fully-bundled/go/main.go b/test/scenarios/bundling/fully-bundled/go/main.go new file mode 100644 index 000000000..5543f6b4d --- /dev/null +++ b/test/scenarios/bundling/fully-bundled/go/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + // Go SDK auto-reads COPILOT_CLI_PATH from env + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/bundling/fully-bundled/python/main.py b/test/scenarios/bundling/fully-bundled/python/main.py new file mode 100644 index 000000000..138bb5646 --- /dev/null +++ b/test/scenarios/bundling/fully-bundled/python/main.py @@ -0,0 +1,27 @@ +import asyncio +import os +from copilot import CopilotClient + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session({"model": "claude-haiku-4.5"}) + + response = await session.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/bundling/fully-bundled/python/requirements.txt b/test/scenarios/bundling/fully-bundled/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/bundling/fully-bundled/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/bundling/fully-bundled/typescript/package.json b/test/scenarios/bundling/fully-bundled/typescript/package.json new file mode 100644 index 000000000..c4d7a93b6 --- /dev/null +++ b/test/scenarios/bundling/fully-bundled/typescript/package.json @@ -0,0 +1,19 @@ +{ + "name": "bundling-fully-bundled-typescript", + "version": "1.0.0", + "private": true, + "description": "Fully-bundled Copilot SDK sample — spawns Copilot CLI via stdio", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.5.0" + } +} diff --git a/test/scenarios/bundling/fully-bundled/typescript/src/index.ts b/test/scenarios/bundling/fully-bundled/typescript/src/index.ts new file mode 100644 index 000000000..989a0b9a6 --- /dev/null +++ b/test/scenarios/bundling/fully-bundled/typescript/src/index.ts @@ -0,0 +1,29 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ model: "claude-haiku-4.5" }); + + const response = await session.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/bundling/fully-bundled/typescript/tsconfig.json b/test/scenarios/bundling/fully-bundled/typescript/tsconfig.json new file mode 100644 index 000000000..8e7a1798c --- /dev/null +++ b/test/scenarios/bundling/fully-bundled/typescript/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/test/scenarios/bundling/fully-bundled/verify.sh b/test/scenarios/bundling/fully-bundled/verify.sh new file mode 100755 index 000000000..fe7c8087e --- /dev/null +++ b/test/scenarios/bundling/fully-bundled/verify.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + echo "$output" + echo "✅ $name passed (got response)" + PASS=$((PASS + 1)) + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying fully-bundled samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o fully-bundled-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c " + cd '$SCRIPT_DIR/typescript' && \ + output=\$(node dist/index.js 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response' +" + +# Python: run +run_with_timeout "Python (run)" bash -c " + cd '$SCRIPT_DIR/python' && \ + output=\$(python3 main.py 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response' +" + +# Go: run +run_with_timeout "Go (run)" bash -c " + cd '$SCRIPT_DIR/go' && \ + output=\$(./fully-bundled-go 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response' +" + +# C#: run +run_with_timeout "C# (run)" bash -c " + cd '$SCRIPT_DIR/csharp' && \ + output=\$(dotnet run --no-build 2>&1) && \ + echo \"\$output\" && \ + echo \"\$output\" | grep -qi 'Paris\|capital\|France\|response' +" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/callbacks/hooks/README.md b/test/scenarios/callbacks/hooks/README.md new file mode 100644 index 000000000..14f4d3784 --- /dev/null +++ b/test/scenarios/callbacks/hooks/README.md @@ -0,0 +1,40 @@ +# configs/hooks — Session Lifecycle Hooks + +Demonstrates all SDK session lifecycle hooks firing during a typical prompt–tool–response cycle. + +## Hooks Tested + +| Hook | When It Fires | Purpose | +|------|---------------|---------| +| `onSessionStart` | Session is created | Initialize logging, metrics, or state | +| `onSessionEnd` | Session is destroyed | Clean up resources, flush logs | +| `onPreToolUse` | Before a tool executes | Approve/deny tool calls, audit usage | +| `onPostToolUse` | After a tool executes | Log results, collect metrics | +| `onUserPromptSubmitted` | User sends a prompt | Transform, validate, or log prompts | +| `onErrorOccurred` | An error is raised | Centralized error handling | + +## What This Scenario Does + +1. Creates a session with **all** lifecycle hooks registered. +2. Each hook appends its name to a log list when invoked. +3. Sends a prompt that triggers tool use (glob file listing). +4. Prints the model's response followed by the hook execution log showing which hooks fired and in what order. + +## Run + +```bash +# TypeScript +cd typescript && npm install && npm run build && node dist/index.js + +# Python +cd python && pip install -r requirements.txt && python3 main.py + +# Go +cd go && go run . +``` + +## Verify All + +```bash +./verify.sh +``` diff --git a/test/scenarios/callbacks/hooks/csharp/Program.cs b/test/scenarios/callbacks/hooks/csharp/Program.cs new file mode 100644 index 000000000..14579e3d0 --- /dev/null +++ b/test/scenarios/callbacks/hooks/csharp/Program.cs @@ -0,0 +1,75 @@ +using GitHub.Copilot.SDK; + +var hookLog = new List(); + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + OnPermissionRequest = (request, invocation) => + Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + Hooks = new SessionHooks + { + OnSessionStart = (input, invocation) => + { + hookLog.Add("onSessionStart"); + return Task.FromResult(null); + }, + OnSessionEnd = (input, invocation) => + { + hookLog.Add("onSessionEnd"); + return Task.FromResult(null); + }, + OnPreToolUse = (input, invocation) => + { + hookLog.Add($"onPreToolUse:{input.ToolName}"); + return Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }); + }, + OnPostToolUse = (input, invocation) => + { + hookLog.Add($"onPostToolUse:{input.ToolName}"); + return Task.FromResult(null); + }, + OnUserPromptSubmitted = (input, invocation) => + { + hookLog.Add("onUserPromptSubmitted"); + return Task.FromResult(null); + }, + OnErrorOccurred = (input, invocation) => + { + hookLog.Add($"onErrorOccurred:{input.Error}"); + return Task.FromResult(null); + }, + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "List the files in the current directory using the glob tool with pattern '*.md'.", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } + + Console.WriteLine("\n--- Hook execution log ---"); + foreach (var entry in hookLog) + { + Console.WriteLine($" {entry}"); + } + Console.WriteLine($"\nTotal hooks fired: {hookLog.Count}"); +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/callbacks/hooks/csharp/csharp.csproj b/test/scenarios/callbacks/hooks/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/callbacks/hooks/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/callbacks/hooks/go/go.mod b/test/scenarios/callbacks/hooks/go/go.mod new file mode 100644 index 000000000..51b27e491 --- /dev/null +++ b/test/scenarios/callbacks/hooks/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/callbacks/hooks/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/callbacks/hooks/go/go.sum b/test/scenarios/callbacks/hooks/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/callbacks/hooks/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/callbacks/hooks/go/main.go b/test/scenarios/callbacks/hooks/go/main.go new file mode 100644 index 000000000..7b1b1a59b --- /dev/null +++ b/test/scenarios/callbacks/hooks/go/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "sync" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + var ( + hookLog []string + hookLogMu sync.Mutex + ) + + appendLog := func(entry string) { + hookLogMu.Lock() + hookLog = append(hookLog, entry) + hookLogMu.Unlock() + } + + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: "approved"}, nil + }, + Hooks: &copilot.SessionHooks{ + OnSessionStart: func(input copilot.SessionStartHookInput, inv copilot.HookInvocation) (*copilot.SessionStartHookOutput, error) { + appendLog("onSessionStart") + return nil, nil + }, + OnSessionEnd: func(input copilot.SessionEndHookInput, inv copilot.HookInvocation) (*copilot.SessionEndHookOutput, error) { + appendLog("onSessionEnd") + return nil, nil + }, + OnPreToolUse: func(input copilot.PreToolUseHookInput, inv copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { + appendLog(fmt.Sprintf("onPreToolUse:%s", input.ToolName)) + return &copilot.PreToolUseHookOutput{PermissionDecision: "allow"}, nil + }, + OnPostToolUse: func(input copilot.PostToolUseHookInput, inv copilot.HookInvocation) (*copilot.PostToolUseHookOutput, error) { + appendLog(fmt.Sprintf("onPostToolUse:%s", input.ToolName)) + return nil, nil + }, + OnUserPromptSubmitted: func(input copilot.UserPromptSubmittedHookInput, inv copilot.HookInvocation) (*copilot.UserPromptSubmittedHookOutput, error) { + appendLog("onUserPromptSubmitted") + return &copilot.UserPromptSubmittedHookOutput{ModifiedPrompt: input.Prompt}, nil + }, + OnErrorOccurred: func(input copilot.ErrorOccurredHookInput, inv copilot.HookInvocation) (*copilot.ErrorOccurredHookOutput, error) { + appendLog(fmt.Sprintf("onErrorOccurred:%s", input.Error)) + return nil, nil + }, + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "List the files in the current directory using the glob tool with pattern '*.md'.", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } + + fmt.Println("\n--- Hook execution log ---") + hookLogMu.Lock() + for _, entry := range hookLog { + fmt.Printf(" %s\n", entry) + } + fmt.Printf("\nTotal hooks fired: %d\n", len(hookLog)) + hookLogMu.Unlock() +} diff --git a/test/scenarios/callbacks/hooks/python/main.py b/test/scenarios/callbacks/hooks/python/main.py new file mode 100644 index 000000000..a00c18af7 --- /dev/null +++ b/test/scenarios/callbacks/hooks/python/main.py @@ -0,0 +1,83 @@ +import asyncio +import os +from copilot import CopilotClient + + +hook_log: list[str] = [] + + +async def auto_approve_permission(request, invocation): + return {"kind": "approved"} + + +async def on_session_start(input_data, invocation): + hook_log.append("onSessionStart") + + +async def on_session_end(input_data, invocation): + hook_log.append("onSessionEnd") + + +async def on_pre_tool_use(input_data, invocation): + tool_name = input_data.get("toolName", "unknown") + hook_log.append(f"onPreToolUse:{tool_name}") + return {"permissionDecision": "allow"} + + +async def on_post_tool_use(input_data, invocation): + tool_name = input_data.get("toolName", "unknown") + hook_log.append(f"onPostToolUse:{tool_name}") + + +async def on_user_prompt_submitted(input_data, invocation): + hook_log.append("onUserPromptSubmitted") + return input_data + + +async def on_error_occurred(input_data, invocation): + error = input_data.get("error", "unknown") + hook_log.append(f"onErrorOccurred:{error}") + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session( + { + "model": "claude-haiku-4.5", + "on_permission_request": auto_approve_permission, + "hooks": { + "on_session_start": on_session_start, + "on_session_end": on_session_end, + "on_pre_tool_use": on_pre_tool_use, + "on_post_tool_use": on_post_tool_use, + "on_user_prompt_submitted": on_user_prompt_submitted, + "on_error_occurred": on_error_occurred, + }, + } + ) + + response = await session.send_and_wait( + { + "prompt": "List the files in the current directory using the glob tool with pattern '*.md'.", + } + ) + + if response: + print(response.data.content) + + await session.destroy() + + print("\n--- Hook execution log ---") + for entry in hook_log: + print(f" {entry}") + print(f"\nTotal hooks fired: {len(hook_log)}") + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/callbacks/hooks/python/requirements.txt b/test/scenarios/callbacks/hooks/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/callbacks/hooks/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/callbacks/hooks/typescript/package.json b/test/scenarios/callbacks/hooks/typescript/package.json new file mode 100644 index 000000000..54c2d4ed0 --- /dev/null +++ b/test/scenarios/callbacks/hooks/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "callbacks-hooks-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — session lifecycle hooks", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/callbacks/hooks/typescript/src/index.ts b/test/scenarios/callbacks/hooks/typescript/src/index.ts new file mode 100644 index 000000000..52708d8fd --- /dev/null +++ b/test/scenarios/callbacks/hooks/typescript/src/index.ts @@ -0,0 +1,62 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const hookLog: string[] = []; + + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + onPermissionRequest: async () => ({ kind: "approved" as const }), + hooks: { + onSessionStart: async () => { + hookLog.push("onSessionStart"); + }, + onSessionEnd: async () => { + hookLog.push("onSessionEnd"); + }, + onPreToolUse: async (input) => { + hookLog.push(`onPreToolUse:${input.toolName}`); + return { permissionDecision: "allow" as const }; + }, + onPostToolUse: async (input) => { + hookLog.push(`onPostToolUse:${input.toolName}`); + }, + onUserPromptSubmitted: async (input) => { + hookLog.push("onUserPromptSubmitted"); + return input; + }, + onErrorOccurred: async (input) => { + hookLog.push(`onErrorOccurred:${input.error}`); + }, + }, + }); + + const response = await session.sendAndWait({ + prompt: "List the files in the current directory using the glob tool with pattern '*.md'.", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + + console.log("\n--- Hook execution log ---"); + for (const entry of hookLog) { + console.log(` ${entry}`); + } + console.log(`\nTotal hooks fired: ${hookLog.length}`); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/callbacks/hooks/verify.sh b/test/scenarios/callbacks/hooks/verify.sh new file mode 100755 index 000000000..8157fed78 --- /dev/null +++ b/test/scenarios/callbacks/hooks/verify.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + local missing="" + if ! echo "$output" | grep -q "onSessionStart\|on_session_start\|OnSessionStart"; then + missing="$missing onSessionStart" + fi + if ! echo "$output" | grep -q "onPreToolUse\|on_pre_tool_use\|OnPreToolUse"; then + missing="$missing onPreToolUse" + fi + if ! echo "$output" | grep -q "onPostToolUse\|on_post_tool_use\|OnPostToolUse"; then + missing="$missing onPostToolUse" + fi + if ! echo "$output" | grep -q "onSessionEnd\|on_session_end\|OnSessionEnd"; then + missing="$missing onSessionEnd" + fi + if [ -z "$missing" ]; then + echo "✅ $name passed (all hooks confirmed)" + PASS=$((PASS + 1)) + else + echo "❌ $name failed (missing hooks:$missing)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (missing:$missing)" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying callbacks/hooks" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + build +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o hooks-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./hooks-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/callbacks/permissions/README.md b/test/scenarios/callbacks/permissions/README.md new file mode 100644 index 000000000..19945235f --- /dev/null +++ b/test/scenarios/callbacks/permissions/README.md @@ -0,0 +1,45 @@ +# Config Sample: Permissions + +Demonstrates the **permission request flow** — the runtime asks the SDK for permission before executing tools, and the SDK can approve or deny each request. This sample approves all requests while logging which tools were invoked. + +This pattern is the foundation for: +- **Enterprise policy enforcement** where certain tools are restricted +- **Audit logging** where all tool invocations must be recorded +- **Interactive approval UIs** where a human confirms sensitive operations +- **Fine-grained access control** based on tool name, arguments, or context + +## How It Works + +1. **Enable `onPermissionRequest` handler** on the session config +2. **Track which tools requested permission** in a log array +3. **Approve all permission requests** (return `kind: "approved"`) +4. **Send a prompt that triggers tool use** (e.g., listing files via glob) +5. **Print the permission log** showing which tools were approved + +## What Each Sample Does + +1. Creates a session with an `onPermissionRequest` callback that logs and approves +2. Sends: _"List the files in the current directory using glob with pattern '*'."_ +3. The runtime calls `onPermissionRequest` before each tool execution +4. The callback records `approved:` and returns approval +5. Prints the agent's response +6. Dumps the permission log showing all approved tool invocations + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `onPermissionRequest` | Log + approve | Records tool name, returns `approved` | +| `hooks.onPreToolUse` | Auto-allow | No tool confirmation prompts | + +## Key Insight + +The `onPermissionRequest` handler gives the integrator full control over which tools the agent can execute. By inspecting the request (tool name, arguments), you can implement allow/deny lists, require human approval for dangerous operations, or log every action for compliance. Returning `{ kind: "denied" }` blocks the tool from running. + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/callbacks/permissions/csharp/Program.cs b/test/scenarios/callbacks/permissions/csharp/Program.cs new file mode 100644 index 000000000..be00015a9 --- /dev/null +++ b/test/scenarios/callbacks/permissions/csharp/Program.cs @@ -0,0 +1,53 @@ +using GitHub.Copilot.SDK; + +var permissionLog = new List(); + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + OnPermissionRequest = (request, invocation) => + { + var toolName = request.ExtensionData?.TryGetValue("toolName", out var value) == true + ? value?.ToString() ?? "unknown" + : "unknown"; + permissionLog.Add($"approved:{toolName}"); + return Task.FromResult(new PermissionRequestResult { Kind = "approved" }); + }, + Hooks = new SessionHooks + { + OnPreToolUse = (input, invocation) => + Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }), + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "List the files in the current directory using glob with pattern '*.md'.", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } + + Console.WriteLine("\n--- Permission request log ---"); + foreach (var entry in permissionLog) + { + Console.WriteLine($" {entry}"); + } + Console.WriteLine($"\nTotal permission requests: {permissionLog.Count}"); +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/callbacks/permissions/csharp/csharp.csproj b/test/scenarios/callbacks/permissions/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/callbacks/permissions/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/callbacks/permissions/go/go.mod b/test/scenarios/callbacks/permissions/go/go.mod new file mode 100644 index 000000000..25eb7d22a --- /dev/null +++ b/test/scenarios/callbacks/permissions/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/callbacks/permissions/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/callbacks/permissions/go/go.sum b/test/scenarios/callbacks/permissions/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/callbacks/permissions/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/callbacks/permissions/go/main.go b/test/scenarios/callbacks/permissions/go/main.go new file mode 100644 index 000000000..7dad320c3 --- /dev/null +++ b/test/scenarios/callbacks/permissions/go/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "sync" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + var ( + permissionLog []string + permissionLogMu sync.Mutex + ) + + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + permissionLogMu.Lock() + toolName, _ := req.Extra["toolName"].(string) + permissionLog = append(permissionLog, fmt.Sprintf("approved:%s", toolName)) + permissionLogMu.Unlock() + return copilot.PermissionRequestResult{Kind: "approved"}, nil + }, + Hooks: &copilot.SessionHooks{ + OnPreToolUse: func(input copilot.PreToolUseHookInput, inv copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { + return &copilot.PreToolUseHookOutput{PermissionDecision: "allow"}, nil + }, + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "List the files in the current directory using glob with pattern '*.md'.", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } + + fmt.Println("\n--- Permission request log ---") + for _, entry := range permissionLog { + fmt.Printf(" %s\n", entry) + } + fmt.Printf("\nTotal permission requests: %d\n", len(permissionLog)) +} diff --git a/test/scenarios/callbacks/permissions/python/main.py b/test/scenarios/callbacks/permissions/python/main.py new file mode 100644 index 000000000..2da5133fa --- /dev/null +++ b/test/scenarios/callbacks/permissions/python/main.py @@ -0,0 +1,52 @@ +import asyncio +import os +from copilot import CopilotClient + +# Track which tools requested permission +permission_log: list[str] = [] + + +async def log_permission(request, invocation): + permission_log.append(f"approved:{request.tool_name}") + return {"kind": "approved"} + + +async def auto_approve_tool(input_data, invocation): + return {"permissionDecision": "allow"} + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session( + { + "model": "claude-haiku-4.5", + "on_permission_request": log_permission, + "hooks": {"on_pre_tool_use": auto_approve_tool}, + } + ) + + response = await session.send_and_wait( + { + "prompt": "List the files in the current directory using glob with pattern '*.md'." + } + ) + + if response: + print(response.data.content) + + await session.destroy() + + print("\n--- Permission request log ---") + for entry in permission_log: + print(f" {entry}") + print(f"\nTotal permission requests: {len(permission_log)}") + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/callbacks/permissions/python/requirements.txt b/test/scenarios/callbacks/permissions/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/callbacks/permissions/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/callbacks/permissions/typescript/package.json b/test/scenarios/callbacks/permissions/typescript/package.json new file mode 100644 index 000000000..a88b00e73 --- /dev/null +++ b/test/scenarios/callbacks/permissions/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "callbacks-permissions-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — permission request flow for tool execution", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/callbacks/permissions/typescript/src/index.ts b/test/scenarios/callbacks/permissions/typescript/src/index.ts new file mode 100644 index 000000000..a7e452cc7 --- /dev/null +++ b/test/scenarios/callbacks/permissions/typescript/src/index.ts @@ -0,0 +1,49 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const permissionLog: string[] = []; + + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { + cliPath: process.env.COPILOT_CLI_PATH, + }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + onPermissionRequest: async (request) => { + permissionLog.push(`approved:${request.toolName}`); + return { kind: "approved" as const }; + }, + hooks: { + onPreToolUse: async () => ({ permissionDecision: "allow" as const }), + }, + }); + + const response = await session.sendAndWait({ + prompt: + "List the files in the current directory using glob with pattern '*.md'.", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + + console.log("\n--- Permission request log ---"); + for (const entry of permissionLog) { + console.log(` ${entry}`); + } + console.log(`\nTotal permission requests: ${permissionLog.length}`); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/callbacks/permissions/verify.sh b/test/scenarios/callbacks/permissions/verify.sh new file mode 100755 index 000000000..bc4af1f6a --- /dev/null +++ b/test/scenarios/callbacks/permissions/verify.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + local missing="" + if ! echo "$output" | grep -qi "approved:"; then + missing="$missing approved-string" + fi + if ! echo "$output" | grep -qE "Total permission requests: [1-9]"; then + missing="$missing permission-count>0" + fi + if [ -z "$missing" ]; then + echo "✅ $name passed (permission flow confirmed)" + PASS=$((PASS + 1)) + else + echo "❌ $name failed (missing:$missing)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (missing:$missing)" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying callbacks/permissions" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o permissions-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./permissions-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/callbacks/user-input/README.md b/test/scenarios/callbacks/user-input/README.md new file mode 100644 index 000000000..fc1482df1 --- /dev/null +++ b/test/scenarios/callbacks/user-input/README.md @@ -0,0 +1,32 @@ +# Config Sample: User Input Request + +Demonstrates the **user input request flow** — the runtime's `ask_user` tool triggers a callback to the SDK, allowing the host application to programmatically respond to agent questions without human interaction. + +This pattern is useful for: +- **Automated pipelines** where answers are predetermined or fetched from config +- **Custom UIs** that intercept user input requests and present their own dialogs +- **Testing** agent flows that require user interaction + +## How It Works + +1. **Enable `onUserInputRequest` callback** on the session +2. The callback auto-responds with `"Paris"` whenever the agent asks a question via `ask_user` +3. **Send a prompt** that instructs the agent to use `ask_user` to ask which city the user is interested in +4. The agent receives `"Paris"` as the answer and tells us about it +5. Print the response and confirm the user input flow worked via a log + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `onUserInputRequest` | Returns `{ answer: "Paris", wasFreeform: true }` | Auto-responds to `ask_user` tool calls | +| `onPermissionRequest` | Auto-approve | No permission dialogs | +| `hooks.onPreToolUse` | Auto-allow | No tool confirmation prompts | + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/callbacks/user-input/csharp/Program.cs b/test/scenarios/callbacks/user-input/csharp/Program.cs new file mode 100644 index 000000000..0ffed2469 --- /dev/null +++ b/test/scenarios/callbacks/user-input/csharp/Program.cs @@ -0,0 +1,52 @@ +using GitHub.Copilot.SDK; + +var inputLog = new List(); + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + OnPermissionRequest = (request, invocation) => + Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + OnUserInputRequest = (request, invocation) => + { + inputLog.Add($"question: {request.Question}"); + return Task.FromResult(new UserInputResponse { Answer = "Paris", WasFreeform = true }); + }, + Hooks = new SessionHooks + { + OnPreToolUse = (input, invocation) => + Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }), + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "I want to learn about a city. Use the ask_user tool to ask me which city I'm interested in. Then tell me about that city.", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } + + Console.WriteLine("\n--- User input log ---"); + foreach (var entry in inputLog) + { + Console.WriteLine($" {entry}"); + } + Console.WriteLine($"\nTotal user input requests: {inputLog.Count}"); +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/callbacks/user-input/csharp/csharp.csproj b/test/scenarios/callbacks/user-input/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/callbacks/user-input/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/callbacks/user-input/go/go.mod b/test/scenarios/callbacks/user-input/go/go.mod new file mode 100644 index 000000000..11419b634 --- /dev/null +++ b/test/scenarios/callbacks/user-input/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/callbacks/user-input/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/callbacks/user-input/go/go.sum b/test/scenarios/callbacks/user-input/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/callbacks/user-input/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/callbacks/user-input/go/main.go b/test/scenarios/callbacks/user-input/go/main.go new file mode 100644 index 000000000..9405de035 --- /dev/null +++ b/test/scenarios/callbacks/user-input/go/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "sync" + + copilot "github.com/github/copilot-sdk/go" +) + +var ( + inputLog []string + inputLogMu sync.Mutex +) + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: "approved"}, nil + }, + OnUserInputRequest: func(req copilot.UserInputRequest, inv copilot.UserInputInvocation) (copilot.UserInputResponse, error) { + inputLogMu.Lock() + inputLog = append(inputLog, fmt.Sprintf("question: %s", req.Question)) + inputLogMu.Unlock() + return copilot.UserInputResponse{Answer: "Paris", WasFreeform: true}, nil + }, + Hooks: &copilot.SessionHooks{ + OnPreToolUse: func(input copilot.PreToolUseHookInput, inv copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { + return &copilot.PreToolUseHookOutput{PermissionDecision: "allow"}, nil + }, + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "I want to learn about a city. Use the ask_user tool to ask me " + + "which city I'm interested in. Then tell me about that city.", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } + + fmt.Println("\n--- User input log ---") + for _, entry := range inputLog { + fmt.Printf(" %s\n", entry) + } + fmt.Printf("\nTotal user input requests: %d\n", len(inputLog)) +} diff --git a/test/scenarios/callbacks/user-input/python/main.py b/test/scenarios/callbacks/user-input/python/main.py new file mode 100644 index 000000000..fb36eda5c --- /dev/null +++ b/test/scenarios/callbacks/user-input/python/main.py @@ -0,0 +1,60 @@ +import asyncio +import os +from copilot import CopilotClient + + +input_log: list[str] = [] + + +async def auto_approve_permission(request, invocation): + return {"kind": "approved"} + + +async def auto_approve_tool(input_data, invocation): + return {"permissionDecision": "allow"} + + +async def handle_user_input(request, invocation): + input_log.append(f"question: {request['question']}") + return {"answer": "Paris", "wasFreeform": True} + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session( + { + "model": "claude-haiku-4.5", + "on_permission_request": auto_approve_permission, + "on_user_input_request": handle_user_input, + "hooks": {"on_pre_tool_use": auto_approve_tool}, + } + ) + + response = await session.send_and_wait( + { + "prompt": ( + "I want to learn about a city. Use the ask_user tool to ask me " + "which city I'm interested in. Then tell me about that city." + ) + } + ) + + if response: + print(response.data.content) + + await session.destroy() + + print("\n--- User input log ---") + for entry in input_log: + print(f" {entry}") + print(f"\nTotal user input requests: {len(input_log)}") + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/callbacks/user-input/python/requirements.txt b/test/scenarios/callbacks/user-input/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/callbacks/user-input/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/callbacks/user-input/typescript/package.json b/test/scenarios/callbacks/user-input/typescript/package.json new file mode 100644 index 000000000..e6c0e3c73 --- /dev/null +++ b/test/scenarios/callbacks/user-input/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "callbacks-user-input-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — user input request flow via ask_user tool", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/callbacks/user-input/typescript/src/index.ts b/test/scenarios/callbacks/user-input/typescript/src/index.ts new file mode 100644 index 000000000..4791fcf10 --- /dev/null +++ b/test/scenarios/callbacks/user-input/typescript/src/index.ts @@ -0,0 +1,47 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const inputLog: string[] = []; + + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + onPermissionRequest: async () => ({ kind: "approved" as const }), + onUserInputRequest: async (request) => { + inputLog.push(`question: ${request.question}`); + return { answer: "Paris", wasFreeform: true }; + }, + hooks: { + onPreToolUse: async () => ({ permissionDecision: "allow" as const }), + }, + }); + + const response = await session.sendAndWait({ + prompt: "I want to learn about a city. Use the ask_user tool to ask me which city I'm interested in. Then tell me about that city.", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + + console.log("\n--- User input log ---"); + for (const entry of inputLog) { + console.log(` ${entry}`); + } + console.log(`\nTotal user input requests: ${inputLog.length}`); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/callbacks/user-input/verify.sh b/test/scenarios/callbacks/user-input/verify.sh new file mode 100755 index 000000000..4550a4c1f --- /dev/null +++ b/test/scenarios/callbacks/user-input/verify.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + local missing="" + if ! echo "$output" | grep -qE "Total user input requests: [1-9]"; then + missing="$missing input-count>0" + fi + if ! echo "$output" | grep -qi "Paris"; then + missing="$missing Paris-in-output" + fi + if [ -z "$missing" ]; then + echo "✅ $name passed (user input flow confirmed)" + PASS=$((PASS + 1)) + else + echo "❌ $name failed (missing:$missing)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (missing:$missing)" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying callbacks/user-input" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + build +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o user-input-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./user-input-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/modes/default/README.md b/test/scenarios/modes/default/README.md new file mode 100644 index 000000000..8bf51cd1e --- /dev/null +++ b/test/scenarios/modes/default/README.md @@ -0,0 +1,7 @@ +# modes/default + +Demonstrates the default agent mode with standard built-in tools. + +Creates a session with only a model specified (no tool overrides), sends a prompt, +and prints the response. The agent has access to all default tools provided by the +Copilot CLI. diff --git a/test/scenarios/modes/default/csharp/Program.cs b/test/scenarios/modes/default/csharp/Program.cs new file mode 100644 index 000000000..974a93036 --- /dev/null +++ b/test/scenarios/modes/default/csharp/Program.cs @@ -0,0 +1,34 @@ +using GitHub.Copilot.SDK; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the grep tool to search for the word 'SDK' in README.md and show the matching lines.", + }); + + if (response != null) + { + Console.WriteLine($"Response: {response.Data?.Content}"); + } + + Console.WriteLine("Default mode test complete"); + +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/modes/default/csharp/csharp.csproj b/test/scenarios/modes/default/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/modes/default/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/modes/default/go/go.mod b/test/scenarios/modes/default/go/go.mod new file mode 100644 index 000000000..50b92181f --- /dev/null +++ b/test/scenarios/modes/default/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/modes/default/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/modes/default/go/go.sum b/test/scenarios/modes/default/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/modes/default/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/modes/default/go/main.go b/test/scenarios/modes/default/go/main.go new file mode 100644 index 000000000..b17ac1e88 --- /dev/null +++ b/test/scenarios/modes/default/go/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "Use the grep tool to search for the word 'SDK' in README.md and show the matching lines.", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Printf("Response: %s\n", *response.Data.Content) + } + + fmt.Println("Default mode test complete") +} diff --git a/test/scenarios/modes/default/python/main.py b/test/scenarios/modes/default/python/main.py new file mode 100644 index 000000000..0abc6b709 --- /dev/null +++ b/test/scenarios/modes/default/python/main.py @@ -0,0 +1,28 @@ +import asyncio +import os +from copilot import CopilotClient + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session({ + "model": "claude-haiku-4.5", + }) + + response = await session.send_and_wait({"prompt": "Use the grep tool to search for the word 'SDK' in README.md and show the matching lines."}) + if response: + print(f"Response: {response.data.content}") + + print("Default mode test complete") + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/modes/default/python/requirements.txt b/test/scenarios/modes/default/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/modes/default/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/modes/default/typescript/package.json b/test/scenarios/modes/default/typescript/package.json new file mode 100644 index 000000000..0696bad60 --- /dev/null +++ b/test/scenarios/modes/default/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "modes-default-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — default agent mode with standard built-in tools", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/modes/default/typescript/src/index.ts b/test/scenarios/modes/default/typescript/src/index.ts new file mode 100644 index 000000000..e10cb6cbc --- /dev/null +++ b/test/scenarios/modes/default/typescript/src/index.ts @@ -0,0 +1,33 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + }); + + const response = await session.sendAndWait({ + prompt: "Use the grep tool to search for the word 'SDK' in README.md and show the matching lines.", + }); + + if (response) { + console.log(`Response: ${response.data.content}`); + } + + console.log("Default mode test complete"); + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/modes/default/verify.sh b/test/scenarios/modes/default/verify.sh new file mode 100755 index 000000000..9d9b78578 --- /dev/null +++ b/test/scenarios/modes/default/verify.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + # Check that the response shows evidence of tool usage or SDK-related content + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -qi "SDK\|readme\|grep\|match\|search"; then + echo "✅ $name passed (confirmed tool usage or SDK content)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name ran but response may not confirm tool usage" + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying modes/default samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o default-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./default-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/modes/minimal/README.md b/test/scenarios/modes/minimal/README.md new file mode 100644 index 000000000..9881fbcc7 --- /dev/null +++ b/test/scenarios/modes/minimal/README.md @@ -0,0 +1,7 @@ +# modes/minimal + +Demonstrates a locked-down agent with all tools removed. + +Creates a session with `availableTools: []` and a custom system message instructing +the agent to respond with text only. Sends a prompt and verifies a text-only response +is returned. diff --git a/test/scenarios/modes/minimal/csharp/Program.cs b/test/scenarios/modes/minimal/csharp/Program.cs new file mode 100644 index 000000000..626e13970 --- /dev/null +++ b/test/scenarios/modes/minimal/csharp/Program.cs @@ -0,0 +1,40 @@ +using GitHub.Copilot.SDK; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + AvailableTools = new List(), + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Replace, + Content = "You have no tools. Respond with text only.", + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the grep tool to search for 'SDK' in README.md.", + }); + + if (response != null) + { + Console.WriteLine($"Response: {response.Data?.Content}"); + } + + Console.WriteLine("Minimal mode test complete"); + +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/modes/minimal/csharp/csharp.csproj b/test/scenarios/modes/minimal/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/modes/minimal/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/modes/minimal/go/go.mod b/test/scenarios/modes/minimal/go/go.mod new file mode 100644 index 000000000..72fbe3540 --- /dev/null +++ b/test/scenarios/modes/minimal/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/modes/minimal/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/modes/minimal/go/go.sum b/test/scenarios/modes/minimal/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/modes/minimal/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/modes/minimal/go/main.go b/test/scenarios/modes/minimal/go/main.go new file mode 100644 index 000000000..1e6d46a53 --- /dev/null +++ b/test/scenarios/modes/minimal/go/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + AvailableTools: []string{}, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: "You have no tools. Respond with text only.", + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "Use the grep tool to search for 'SDK' in README.md.", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Printf("Response: %s\n", *response.Data.Content) + } + + fmt.Println("Minimal mode test complete") +} diff --git a/test/scenarios/modes/minimal/python/main.py b/test/scenarios/modes/minimal/python/main.py new file mode 100644 index 000000000..74a98ba0e --- /dev/null +++ b/test/scenarios/modes/minimal/python/main.py @@ -0,0 +1,33 @@ +import asyncio +import os +from copilot import CopilotClient + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session({ + "model": "claude-haiku-4.5", + "available_tools": [], + "system_message": { + "mode": "replace", + "content": "You have no tools. Respond with text only.", + }, + }) + + response = await session.send_and_wait({"prompt": "Use the grep tool to search for 'SDK' in README.md."}) + if response: + print(f"Response: {response.data.content}") + + print("Minimal mode test complete") + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/modes/minimal/python/requirements.txt b/test/scenarios/modes/minimal/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/modes/minimal/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/modes/minimal/typescript/package.json b/test/scenarios/modes/minimal/typescript/package.json new file mode 100644 index 000000000..4f531cfa0 --- /dev/null +++ b/test/scenarios/modes/minimal/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "modes-minimal-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — locked-down agent with all tools removed", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/modes/minimal/typescript/src/index.ts b/test/scenarios/modes/minimal/typescript/src/index.ts new file mode 100644 index 000000000..091595bec --- /dev/null +++ b/test/scenarios/modes/minimal/typescript/src/index.ts @@ -0,0 +1,38 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + availableTools: [], + systemMessage: { + mode: "replace", + content: "You have no tools. Respond with text only.", + }, + }); + + const response = await session.sendAndWait({ + prompt: "Use the grep tool to search for 'SDK' in README.md.", + }); + + if (response) { + console.log(`Response: ${response.data.content}`); + } + + console.log("Minimal mode test complete"); + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/modes/minimal/verify.sh b/test/scenarios/modes/minimal/verify.sh new file mode 100755 index 000000000..b72b42520 --- /dev/null +++ b/test/scenarios/modes/minimal/verify.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + # Check that the response indicates it can't use tools + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -qi "no tool\|can't\|cannot\|unable\|don't have\|do not have\|not available\|not have access\|no access"; then + echo "✅ $name passed (confirmed no tools)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name ran but response may not confirm tool-less state" + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying modes/minimal samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o minimal-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./minimal-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/prompts/attachments/README.md b/test/scenarios/prompts/attachments/README.md new file mode 100644 index 000000000..8c8239b23 --- /dev/null +++ b/test/scenarios/prompts/attachments/README.md @@ -0,0 +1,44 @@ +# Config Sample: File Attachments + +Demonstrates sending **file attachments** alongside a prompt using the Copilot SDK. This validates that the SDK correctly passes file content to the model and the model can reference it in its response. + +## What Each Sample Does + +1. Creates a session with a custom system prompt in `replace` mode +2. Resolves the path to `sample-data.txt` (a small text file in the scenario root) +3. Sends: _"What languages are listed in the attached file?"_ with the file as an attachment +4. Prints the response — which should list TypeScript, Python, and Go + +## Attachment Format + +| Field | Value | Description | +|-------|-------|-------------| +| `type` | `"file"` | Indicates a local file attachment | +| `path` | Absolute path to file | The SDK reads and sends the file content to the model | + +### Language-Specific Usage + +| Language | Attachment Syntax | +|----------|------------------| +| TypeScript | `attachments: [{ type: "file", path: sampleFile }]` | +| Python | `"attachments": [{"type": "file", "path": sample_file}]` | +| Go | `Attachments: []copilot.Attachment{{Type: "file", Path: sampleFile}}` | + +## Sample Data + +The `sample-data.txt` file contains basic project metadata used as the attachment target: + +``` +Project: Copilot SDK Samples +Version: 1.0.0 +Description: Minimal buildable samples demonstrating the Copilot SDK +Languages: TypeScript, Python, Go +``` + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/prompts/attachments/csharp/Program.cs b/test/scenarios/prompts/attachments/csharp/Program.cs new file mode 100644 index 000000000..9e28c342d --- /dev/null +++ b/test/scenarios/prompts/attachments/csharp/Program.cs @@ -0,0 +1,39 @@ +using GitHub.Copilot.SDK; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + SystemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Replace, Content = "You are a helpful assistant. Answer questions about attached files concisely." }, + AvailableTools = [], + }); + + var sampleFile = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "sample-data.txt")); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What languages are listed in the attached file?", + Attachments = + [ + new UserMessageDataAttachmentsItemFile { Path = sampleFile, DisplayName = "sample-data.txt" }, + ], + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/prompts/attachments/csharp/csharp.csproj b/test/scenarios/prompts/attachments/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/prompts/attachments/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/prompts/attachments/go/go.mod b/test/scenarios/prompts/attachments/go/go.mod new file mode 100644 index 000000000..0a5dc6c1f --- /dev/null +++ b/test/scenarios/prompts/attachments/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/prompts/attachments/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/prompts/attachments/go/go.sum b/test/scenarios/prompts/attachments/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/prompts/attachments/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/prompts/attachments/go/main.go b/test/scenarios/prompts/attachments/go/main.go new file mode 100644 index 000000000..bb1486da2 --- /dev/null +++ b/test/scenarios/prompts/attachments/go/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + + copilot "github.com/github/copilot-sdk/go" +) + +const systemPrompt = `You are a helpful assistant. Answer questions about attached files concisely.` + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: systemPrompt, + }, + AvailableTools: []string{}, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + exe, err := os.Executable() + if err != nil { + log.Fatal(err) + } + sampleFile := filepath.Join(filepath.Dir(exe), "..", "sample-data.txt") + sampleFile, err = filepath.Abs(sampleFile) + if err != nil { + log.Fatal(err) + } + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What languages are listed in the attached file?", + Attachments: []copilot.Attachment{ + {Type: "file", Path: &sampleFile}, + }, + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/prompts/attachments/python/main.py b/test/scenarios/prompts/attachments/python/main.py new file mode 100644 index 000000000..acf9c7af1 --- /dev/null +++ b/test/scenarios/prompts/attachments/python/main.py @@ -0,0 +1,41 @@ +import asyncio +import os +from copilot import CopilotClient + +SYSTEM_PROMPT = """You are a helpful assistant. Answer questions about attached files concisely.""" + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session( + { + "model": "claude-haiku-4.5", + "system_message": {"mode": "replace", "content": SYSTEM_PROMPT}, + "available_tools": [], + } + ) + + sample_file = os.path.join(os.path.dirname(__file__), "..", "sample-data.txt") + sample_file = os.path.abspath(sample_file) + + response = await session.send_and_wait( + { + "prompt": "What languages are listed in the attached file?", + "attachments": [{"type": "file", "path": sample_file}], + } + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/prompts/attachments/python/requirements.txt b/test/scenarios/prompts/attachments/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/prompts/attachments/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/prompts/attachments/sample-data.txt b/test/scenarios/prompts/attachments/sample-data.txt new file mode 100644 index 000000000..ea82ad2d3 --- /dev/null +++ b/test/scenarios/prompts/attachments/sample-data.txt @@ -0,0 +1,4 @@ +Project: Copilot SDK Samples +Version: 1.0.0 +Description: Minimal buildable samples demonstrating the Copilot SDK +Languages: TypeScript, Python, Go diff --git a/test/scenarios/prompts/attachments/typescript/package.json b/test/scenarios/prompts/attachments/typescript/package.json new file mode 100644 index 000000000..4553a73b3 --- /dev/null +++ b/test/scenarios/prompts/attachments/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "prompts-attachments-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — file attachments in messages", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/prompts/attachments/typescript/src/index.ts b/test/scenarios/prompts/attachments/typescript/src/index.ts new file mode 100644 index 000000000..72e601ca2 --- /dev/null +++ b/test/scenarios/prompts/attachments/typescript/src/index.ts @@ -0,0 +1,43 @@ +import { CopilotClient } from "@github/copilot-sdk"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + availableTools: [], + systemMessage: { + mode: "replace", + content: "You are a helpful assistant. Answer questions about attached files concisely.", + }, + }); + + const sampleFile = path.resolve(__dirname, "../../sample-data.txt"); + + const response = await session.sendAndWait({ + prompt: "What languages are listed in the attached file?", + attachments: [{ type: "file", path: sampleFile }], + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/prompts/attachments/verify.sh b/test/scenarios/prompts/attachments/verify.sh new file mode 100755 index 000000000..cf4a91977 --- /dev/null +++ b/test/scenarios/prompts/attachments/verify.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + # Check that the response references languages from the attached file + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -qi "TypeScript\|Python\|Go"; then + echo "✅ $name passed (confirmed file content referenced)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name ran but response may not reference attached file content" + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying prompts/attachments samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o attachments-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./attachments-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/prompts/reasoning-effort/README.md b/test/scenarios/prompts/reasoning-effort/README.md new file mode 100644 index 000000000..e8279a7c8 --- /dev/null +++ b/test/scenarios/prompts/reasoning-effort/README.md @@ -0,0 +1,43 @@ +# Config Sample: Reasoning Effort + +Demonstrates configuring the Copilot SDK with different **reasoning effort** levels. The `reasoningEffort` session config controls how much compute the model spends thinking before responding. + +## Reasoning Effort Levels + +| Level | Effect | +|-------|--------| +| `low` | Fastest responses, minimal reasoning | +| `medium` | Balanced speed and depth | +| `high` | Deeper reasoning, slower responses | +| `xhigh` | Maximum reasoning effort | + +## What This Sample Does + +1. Creates a session with `reasoningEffort: "low"` and `availableTools: []` +2. Sends: _"What is the capital of France?"_ +3. Prints the response — confirming the model responds correctly at low effort + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `reasoningEffort` | `"low"` | Sets minimal reasoning effort | +| `availableTools` | `[]` (empty array) | Removes all built-in tools | +| `systemMessage.mode` | `"replace"` | Replaces the default system prompt | +| `systemMessage.content` | Custom concise prompt | Instructs the agent to answer concisely | + +## Languages + +| Directory | SDK / Approach | Language | +|-----------|---------------|----------| +| `typescript/` | `@github/copilot-sdk` | TypeScript (Node.js) | +| `python/` | `github-copilot-sdk` | Python | +| `go/` | `github.com/github/copilot-sdk/go` | Go | + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/prompts/reasoning-effort/csharp/Program.cs b/test/scenarios/prompts/reasoning-effort/csharp/Program.cs new file mode 100644 index 000000000..c026e046d --- /dev/null +++ b/test/scenarios/prompts/reasoning-effort/csharp/Program.cs @@ -0,0 +1,39 @@ +using GitHub.Copilot.SDK; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-opus-4.6", + ReasoningEffort = "low", + AvailableTools = new List(), + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Replace, + Content = "You are a helpful assistant. Answer concisely.", + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response != null) + { + Console.WriteLine("Reasoning effort: low"); + Console.WriteLine($"Response: {response.Data?.Content}"); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/prompts/reasoning-effort/csharp/csharp.csproj b/test/scenarios/prompts/reasoning-effort/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/prompts/reasoning-effort/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/prompts/reasoning-effort/go/go.mod b/test/scenarios/prompts/reasoning-effort/go/go.mod new file mode 100644 index 000000000..f2aa4740c --- /dev/null +++ b/test/scenarios/prompts/reasoning-effort/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/prompts/reasoning-effort/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/prompts/reasoning-effort/go/go.sum b/test/scenarios/prompts/reasoning-effort/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/prompts/reasoning-effort/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/prompts/reasoning-effort/go/main.go b/test/scenarios/prompts/reasoning-effort/go/main.go new file mode 100644 index 000000000..ce9ffe508 --- /dev/null +++ b/test/scenarios/prompts/reasoning-effort/go/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-opus-4.6", + ReasoningEffort: "low", + AvailableTools: []string{}, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: "You are a helpful assistant. Answer concisely.", + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println("Reasoning effort: low") + fmt.Printf("Response: %s\n", *response.Data.Content) + } +} diff --git a/test/scenarios/prompts/reasoning-effort/python/main.py b/test/scenarios/prompts/reasoning-effort/python/main.py new file mode 100644 index 000000000..74444e7bf --- /dev/null +++ b/test/scenarios/prompts/reasoning-effort/python/main.py @@ -0,0 +1,36 @@ +import asyncio +import os +from copilot import CopilotClient + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session({ + "model": "claude-opus-4.6", + "reasoning_effort": "low", + "available_tools": [], + "system_message": { + "mode": "replace", + "content": "You are a helpful assistant. Answer concisely.", + }, + }) + + response = await session.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response: + print("Reasoning effort: low") + print(f"Response: {response.data.content}") + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/prompts/reasoning-effort/python/requirements.txt b/test/scenarios/prompts/reasoning-effort/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/prompts/reasoning-effort/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/prompts/reasoning-effort/typescript/package.json b/test/scenarios/prompts/reasoning-effort/typescript/package.json new file mode 100644 index 000000000..0d8134f4d --- /dev/null +++ b/test/scenarios/prompts/reasoning-effort/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "prompts-reasoning-effort-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — reasoning effort levels", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts b/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts new file mode 100644 index 000000000..fd2091ef0 --- /dev/null +++ b/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts @@ -0,0 +1,39 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + // Test with "low" reasoning effort + const session = await client.createSession({ + model: "claude-opus-4.6", + reasoningEffort: "low", + availableTools: [], + systemMessage: { + mode: "replace", + content: "You are a helpful assistant. Answer concisely.", + }, + }); + + const response = await session.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response) { + console.log(`Reasoning effort: low`); + console.log(`Response: ${response.data.content}`); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/prompts/reasoning-effort/verify.sh b/test/scenarios/prompts/reasoning-effort/verify.sh new file mode 100755 index 000000000..fe528229e --- /dev/null +++ b/test/scenarios/prompts/reasoning-effort/verify.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + # Note: reasoning effort is configuration-only and can't be verified from output alone. + # We can only confirm a response with actual content was received. + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -qi "Response:\|capital\|Paris\|France"; then + echo "✅ $name passed (confirmed reasoning effort response)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name ran but response may not contain expected content" + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying prompts/reasoning-effort samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + build +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o reasoning-effort-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./reasoning-effort-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/prompts/system-message/README.md b/test/scenarios/prompts/system-message/README.md new file mode 100644 index 000000000..1615393f0 --- /dev/null +++ b/test/scenarios/prompts/system-message/README.md @@ -0,0 +1,32 @@ +# Config Sample: System Message + +Demonstrates configuring the Copilot SDK's **system message** using `replace` mode. This validates that a custom system prompt fully replaces the default system prompt, changing the agent's personality and response style. + +## Append vs Replace Modes + +| Mode | Behavior | +|------|----------| +| `"append"` | Adds your content **after** the default system prompt. The agent retains its base personality plus your additions. | +| `"replace"` | **Replaces** the entire default system prompt with your content. The agent's personality is fully defined by your prompt. | + +## What Each Sample Does + +1. Creates a session with `systemMessage` in `replace` mode using a pirate personality prompt +2. Sends: _"What is the capital of France?"_ +3. Prints the response — which should be in pirate speak (containing "Arrr!", nautical terms, etc.) + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `systemMessage.mode` | `"replace"` | Replaces the default system prompt entirely | +| `systemMessage.content` | Pirate personality prompt | Instructs the agent to always respond in pirate speak | +| `availableTools` | `[]` (empty array) | No tools — focuses the test on system message behavior | + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/prompts/system-message/csharp/Program.cs b/test/scenarios/prompts/system-message/csharp/Program.cs new file mode 100644 index 000000000..7b13d173c --- /dev/null +++ b/test/scenarios/prompts/system-message/csharp/Program.cs @@ -0,0 +1,39 @@ +using GitHub.Copilot.SDK; + +var piratePrompt = "You are a pirate. Always respond in pirate speak. Say 'Arrr!' in every response. Use nautical terms and pirate slang throughout."; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Replace, + Content = piratePrompt, + }, + AvailableTools = [], + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/prompts/system-message/csharp/csharp.csproj b/test/scenarios/prompts/system-message/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/prompts/system-message/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/prompts/system-message/go/go.mod b/test/scenarios/prompts/system-message/go/go.mod new file mode 100644 index 000000000..b8301c15a --- /dev/null +++ b/test/scenarios/prompts/system-message/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/prompts/system-message/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/prompts/system-message/go/go.sum b/test/scenarios/prompts/system-message/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/prompts/system-message/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/prompts/system-message/go/main.go b/test/scenarios/prompts/system-message/go/main.go new file mode 100644 index 000000000..34e9c7523 --- /dev/null +++ b/test/scenarios/prompts/system-message/go/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +const piratePrompt = `You are a pirate. Always respond in pirate speak. Say 'Arrr!' in every response. Use nautical terms and pirate slang throughout.` + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: piratePrompt, + }, + AvailableTools: []string{}, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/prompts/system-message/python/main.py b/test/scenarios/prompts/system-message/python/main.py new file mode 100644 index 000000000..a3bfccdcf --- /dev/null +++ b/test/scenarios/prompts/system-message/python/main.py @@ -0,0 +1,35 @@ +import asyncio +import os +from copilot import CopilotClient + +PIRATE_PROMPT = """You are a pirate. Always respond in pirate speak. Say 'Arrr!' in every response. Use nautical terms and pirate slang throughout.""" + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session( + { + "model": "claude-haiku-4.5", + "system_message": {"mode": "replace", "content": PIRATE_PROMPT}, + "available_tools": [], + } + ) + + response = await session.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/prompts/system-message/python/requirements.txt b/test/scenarios/prompts/system-message/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/prompts/system-message/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/prompts/system-message/typescript/package.json b/test/scenarios/prompts/system-message/typescript/package.json new file mode 100644 index 000000000..79e746891 --- /dev/null +++ b/test/scenarios/prompts/system-message/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "prompts-system-message-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — system message append vs replace modes", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/prompts/system-message/typescript/src/index.ts b/test/scenarios/prompts/system-message/typescript/src/index.ts new file mode 100644 index 000000000..dc518069b --- /dev/null +++ b/test/scenarios/prompts/system-message/typescript/src/index.ts @@ -0,0 +1,35 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +const PIRATE_PROMPT = `You are a pirate. Always respond in pirate speak. Say 'Arrr!' in every response. Use nautical terms and pirate slang throughout.`; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + systemMessage: { mode: "replace", content: PIRATE_PROMPT }, + availableTools: [], + }); + + const response = await session.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/prompts/system-message/verify.sh b/test/scenarios/prompts/system-message/verify.sh new file mode 100755 index 000000000..c2699768b --- /dev/null +++ b/test/scenarios/prompts/system-message/verify.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + # Check that the response contains pirate language + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -qi "arrr\|pirate\|matey\|ahoy\|ye\|sail"; then + echo "✅ $name passed (confirmed pirate speak)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name ran but response may not contain pirate language" + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying prompts/system-message samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o system-message-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./system-message-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/sessions/concurrent-sessions/README.md b/test/scenarios/sessions/concurrent-sessions/README.md new file mode 100644 index 000000000..0b82a66ae --- /dev/null +++ b/test/scenarios/sessions/concurrent-sessions/README.md @@ -0,0 +1,33 @@ +# Config Sample: Concurrent Sessions + +Demonstrates creating **multiple sessions on the same client** with different configurations and verifying that each session maintains its own isolated state. + +## What This Tests + +1. **Session isolation** — Two sessions created on the same client receive different system prompts and respond according to their own persona, not the other's. +2. **Concurrent operation** — Both sessions can be used in parallel without interference. + +## What Each Sample Does + +1. Creates a client, then opens two sessions concurrently: + - **Session 1** — system prompt: _"You are a pirate. Always say Arrr!"_ + - **Session 2** — system prompt: _"You are a robot. Always say BEEP BOOP!"_ +2. Sends the same question (_"What is the capital of France?"_) to both sessions +3. Prints both responses with labels (`Session 1 (pirate):` and `Session 2 (robot):`) +4. Destroys both sessions + +## Configuration + +| Option | Session 1 | Session 2 | +|--------|-----------|-----------| +| `systemMessage.mode` | `"replace"` | `"replace"` | +| `systemMessage.content` | Pirate persona | Robot persona | +| `availableTools` | `[]` | `[]` | + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/sessions/concurrent-sessions/csharp/Program.cs b/test/scenarios/sessions/concurrent-sessions/csharp/Program.cs new file mode 100644 index 000000000..f3f1b3688 --- /dev/null +++ b/test/scenarios/sessions/concurrent-sessions/csharp/Program.cs @@ -0,0 +1,58 @@ +using GitHub.Copilot.SDK; + +const string PiratePrompt = "You are a pirate. Always say Arrr!"; +const string RobotPrompt = "You are a robot. Always say BEEP BOOP!"; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + var session1Task = client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + SystemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Replace, Content = PiratePrompt }, + AvailableTools = [], + }); + + var session2Task = client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + SystemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Replace, Content = RobotPrompt }, + AvailableTools = [], + }); + + await using var session1 = await session1Task; + await using var session2 = await session2Task; + + var response1Task = session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + var response2Task = session2.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + var response1 = await response1Task; + var response2 = await response2Task; + + if (response1 != null) + { + Console.WriteLine($"Session 1 (pirate): {response1.Data?.Content}"); + } + if (response2 != null) + { + Console.WriteLine($"Session 2 (robot): {response2.Data?.Content}"); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/sessions/concurrent-sessions/csharp/csharp.csproj b/test/scenarios/sessions/concurrent-sessions/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/sessions/concurrent-sessions/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/sessions/concurrent-sessions/go/go.mod b/test/scenarios/sessions/concurrent-sessions/go/go.mod new file mode 100644 index 000000000..c01642320 --- /dev/null +++ b/test/scenarios/sessions/concurrent-sessions/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/sessions/concurrent-sessions/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/sessions/concurrent-sessions/go/go.sum b/test/scenarios/sessions/concurrent-sessions/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/sessions/concurrent-sessions/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/sessions/concurrent-sessions/go/main.go b/test/scenarios/sessions/concurrent-sessions/go/main.go new file mode 100644 index 000000000..fa15f445e --- /dev/null +++ b/test/scenarios/sessions/concurrent-sessions/go/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "sync" + + copilot "github.com/github/copilot-sdk/go" +) + +const piratePrompt = `You are a pirate. Always say Arrr!` +const robotPrompt = `You are a robot. Always say BEEP BOOP!` + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session1, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: piratePrompt, + }, + AvailableTools: []string{}, + }) + if err != nil { + log.Fatal(err) + } + defer session1.Destroy() + + session2, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: robotPrompt, + }, + AvailableTools: []string{}, + }) + if err != nil { + log.Fatal(err) + } + defer session2.Destroy() + + type result struct { + label string + content string + } + + var wg sync.WaitGroup + results := make([]result, 2) + + wg.Add(2) + go func() { + defer wg.Done() + resp, err := session1.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + if resp != nil && resp.Data.Content != nil { + results[0] = result{label: "Session 1 (pirate)", content: *resp.Data.Content} + } + }() + go func() { + defer wg.Done() + resp, err := session2.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + if resp != nil && resp.Data.Content != nil { + results[1] = result{label: "Session 2 (robot)", content: *resp.Data.Content} + } + }() + wg.Wait() + + for _, r := range results { + if r.label != "" { + fmt.Printf("%s: %s\n", r.label, r.content) + } + } +} diff --git a/test/scenarios/sessions/concurrent-sessions/python/main.py b/test/scenarios/sessions/concurrent-sessions/python/main.py new file mode 100644 index 000000000..171a202e4 --- /dev/null +++ b/test/scenarios/sessions/concurrent-sessions/python/main.py @@ -0,0 +1,52 @@ +import asyncio +import os +from copilot import CopilotClient + +PIRATE_PROMPT = "You are a pirate. Always say Arrr!" +ROBOT_PROMPT = "You are a robot. Always say BEEP BOOP!" + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session1, session2 = await asyncio.gather( + client.create_session( + { + "model": "claude-haiku-4.5", + "system_message": {"mode": "replace", "content": PIRATE_PROMPT}, + "available_tools": [], + } + ), + client.create_session( + { + "model": "claude-haiku-4.5", + "system_message": {"mode": "replace", "content": ROBOT_PROMPT}, + "available_tools": [], + } + ), + ) + + response1, response2 = await asyncio.gather( + session1.send_and_wait( + {"prompt": "What is the capital of France?"} + ), + session2.send_and_wait( + {"prompt": "What is the capital of France?"} + ), + ) + + if response1: + print("Session 1 (pirate):", response1.data.content) + if response2: + print("Session 2 (robot):", response2.data.content) + + await asyncio.gather(session1.destroy(), session2.destroy()) + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/sessions/concurrent-sessions/python/requirements.txt b/test/scenarios/sessions/concurrent-sessions/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/sessions/concurrent-sessions/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/sessions/concurrent-sessions/typescript/package.json b/test/scenarios/sessions/concurrent-sessions/typescript/package.json new file mode 100644 index 000000000..fabeeda8b --- /dev/null +++ b/test/scenarios/sessions/concurrent-sessions/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "sessions-concurrent-sessions-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — concurrent session isolation", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts b/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts new file mode 100644 index 000000000..80772886a --- /dev/null +++ b/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts @@ -0,0 +1,48 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +const PIRATE_PROMPT = `You are a pirate. Always say Arrr!`; +const ROBOT_PROMPT = `You are a robot. Always say BEEP BOOP!`; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const [session1, session2] = await Promise.all([ + client.createSession({ + model: "claude-haiku-4.5", + systemMessage: { mode: "replace", content: PIRATE_PROMPT }, + availableTools: [], + }), + client.createSession({ + model: "claude-haiku-4.5", + systemMessage: { mode: "replace", content: ROBOT_PROMPT }, + availableTools: [], + }), + ]); + + const [response1, response2] = await Promise.all([ + session1.sendAndWait({ prompt: "What is the capital of France?" }), + session2.sendAndWait({ prompt: "What is the capital of France?" }), + ]); + + if (response1) { + console.log("Session 1 (pirate):", response1.data.content); + } + if (response2) { + console.log("Session 2 (robot):", response2.data.content); + } + + await Promise.all([session1.destroy(), session2.destroy()]); + } finally { + await client.stop(); + process.exit(0); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/sessions/concurrent-sessions/verify.sh b/test/scenarios/sessions/concurrent-sessions/verify.sh new file mode 100755 index 000000000..be4e3d309 --- /dev/null +++ b/test/scenarios/sessions/concurrent-sessions/verify.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=120 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + # Check that both sessions produced output + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + local has_session1=false + local has_session2=false + if echo "$output" | grep -q "Session 1"; then + has_session1=true + fi + if echo "$output" | grep -q "Session 2"; then + has_session2=true + fi + if $has_session1 && $has_session2; then + # Verify persona isolation: pirate language from session 1, robot language from session 2 + local persona_ok=true + if ! echo "$output" | grep -qi "arrr\|pirate\|matey\|ahoy"; then + echo "⚠️ $name: pirate persona words not found in output" + persona_ok=false + fi + if ! echo "$output" | grep -qi "beep\|boop\|robot"; then + echo "⚠️ $name: robot persona words not found in output" + persona_ok=false + fi + if $persona_ok; then + echo "✅ $name passed (both sessions responded with correct personas)" + PASS=$((PASS + 1)) + else + echo "❌ $name failed (persona isolation not verified)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (persona check)" + fi + elif $has_session1 || $has_session2; then + echo "⚠️ $name ran but only one session responded" + echo "❌ $name failed (expected both to respond)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (partial)" + else + echo "⚠️ $name ran but session labels not found in output" + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying sessions/concurrent-sessions samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o concurrent-sessions-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./concurrent-sessions-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/sessions/infinite-sessions/README.md b/test/scenarios/sessions/infinite-sessions/README.md new file mode 100644 index 000000000..78549a68d --- /dev/null +++ b/test/scenarios/sessions/infinite-sessions/README.md @@ -0,0 +1,43 @@ +# Config Sample: Infinite Sessions + +Demonstrates configuring the Copilot SDK with **infinite sessions** enabled, which uses context compaction to allow sessions to continue beyond the model's context window limit. + +## What This Tests + +1. **Config acceptance** — The `infiniteSessions` configuration with compaction thresholds is accepted by the server without errors. +2. **Session continuity** — Multiple messages are sent and responses received successfully with infinite sessions enabled. + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `infiniteSessions.enabled` | `true` | Enables context compaction for the session | +| `infiniteSessions.backgroundCompactionThreshold` | `0.80` | Triggers background compaction at 80% context usage | +| `infiniteSessions.bufferExhaustionThreshold` | `0.95` | Forces compaction at 95% context usage | +| `availableTools` | `[]` | No tools — keeps context small for testing | +| `systemMessage.mode` | `"replace"` | Replaces the default system prompt | + +## How It Works + +When `infiniteSessions` is enabled, the server monitors context window usage. As the conversation grows: + +- At `backgroundCompactionThreshold` (80%), the server begins compacting older messages in the background. +- At `bufferExhaustionThreshold` (95%), compaction is forced before the next message is processed. + +This allows sessions to run indefinitely without hitting context limits. + +## Languages + +| Directory | SDK / Approach | Language | +|-----------|---------------|----------| +| `typescript/` | `@github/copilot-sdk` | TypeScript (Node.js) | +| `python/` | `github-copilot-sdk` | Python | +| `go/` | `github.com/github/copilot-sdk/go` | Go | + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/sessions/infinite-sessions/csharp/Program.cs b/test/scenarios/sessions/infinite-sessions/csharp/Program.cs new file mode 100644 index 000000000..1c6244e4d --- /dev/null +++ b/test/scenarios/sessions/infinite-sessions/csharp/Program.cs @@ -0,0 +1,56 @@ +using GitHub.Copilot.SDK; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + AvailableTools = new List(), + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Replace, + Content = "You are a helpful assistant. Answer concisely in one sentence.", + }, + InfiniteSessions = new InfiniteSessionConfig + { + Enabled = true, + BackgroundCompactionThreshold = 0.80, + BufferExhaustionThreshold = 0.95, + }, + }); + + var prompts = new[] + { + "What is the capital of France?", + "What is the capital of Japan?", + "What is the capital of Brazil?", + }; + + foreach (var prompt in prompts) + { + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = prompt, + }); + + if (response != null) + { + Console.WriteLine($"Q: {prompt}"); + Console.WriteLine($"A: {response.Data?.Content}\n"); + } + } + + Console.WriteLine("Infinite sessions test complete — all messages processed successfully"); +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/sessions/infinite-sessions/csharp/csharp.csproj b/test/scenarios/sessions/infinite-sessions/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/sessions/infinite-sessions/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/sessions/infinite-sessions/go/go.mod b/test/scenarios/sessions/infinite-sessions/go/go.mod new file mode 100644 index 000000000..cb8d2713d --- /dev/null +++ b/test/scenarios/sessions/infinite-sessions/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/sessions/infinite-sessions/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/sessions/infinite-sessions/go/go.sum b/test/scenarios/sessions/infinite-sessions/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/sessions/infinite-sessions/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/sessions/infinite-sessions/go/main.go b/test/scenarios/sessions/infinite-sessions/go/main.go new file mode 100644 index 000000000..c4c95814c --- /dev/null +++ b/test/scenarios/sessions/infinite-sessions/go/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func boolPtr(b bool) *bool { return &b } +func float64Ptr(f float64) *float64 { return &f } + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + AvailableTools: []string{}, + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: "You are a helpful assistant. Answer concisely in one sentence.", + }, + InfiniteSessions: &copilot.InfiniteSessionConfig{ + Enabled: boolPtr(true), + BackgroundCompactionThreshold: float64Ptr(0.80), + BufferExhaustionThreshold: float64Ptr(0.95), + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + prompts := []string{ + "What is the capital of France?", + "What is the capital of Japan?", + "What is the capital of Brazil?", + } + + for _, prompt := range prompts { + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: prompt, + }) + if err != nil { + log.Fatal(err) + } + if response != nil && response.Data.Content != nil { + fmt.Printf("Q: %s\n", prompt) + fmt.Printf("A: %s\n\n", *response.Data.Content) + } + } + + fmt.Println("Infinite sessions test complete — all messages processed successfully") +} diff --git a/test/scenarios/sessions/infinite-sessions/python/main.py b/test/scenarios/sessions/infinite-sessions/python/main.py new file mode 100644 index 000000000..fe39a7117 --- /dev/null +++ b/test/scenarios/sessions/infinite-sessions/python/main.py @@ -0,0 +1,46 @@ +import asyncio +import os +from copilot import CopilotClient + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session({ + "model": "claude-haiku-4.5", + "available_tools": [], + "system_message": { + "mode": "replace", + "content": "You are a helpful assistant. Answer concisely in one sentence.", + }, + "infinite_sessions": { + "enabled": True, + "background_compaction_threshold": 0.80, + "buffer_exhaustion_threshold": 0.95, + }, + }) + + prompts = [ + "What is the capital of France?", + "What is the capital of Japan?", + "What is the capital of Brazil?", + ] + + for prompt in prompts: + response = await session.send_and_wait({"prompt": prompt}) + if response: + print(f"Q: {prompt}") + print(f"A: {response.data.content}\n") + + print("Infinite sessions test complete — all messages processed successfully") + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/sessions/infinite-sessions/python/requirements.txt b/test/scenarios/sessions/infinite-sessions/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/sessions/infinite-sessions/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/sessions/infinite-sessions/typescript/package.json b/test/scenarios/sessions/infinite-sessions/typescript/package.json new file mode 100644 index 000000000..dcc8e776c --- /dev/null +++ b/test/scenarios/sessions/infinite-sessions/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "sessions-infinite-sessions-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — infinite sessions with context compaction", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts b/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts new file mode 100644 index 000000000..a3b3de61c --- /dev/null +++ b/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts @@ -0,0 +1,49 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + availableTools: [], + systemMessage: { + mode: "replace", + content: "You are a helpful assistant. Answer concisely in one sentence.", + }, + infiniteSessions: { + enabled: true, + backgroundCompactionThreshold: 0.80, + bufferExhaustionThreshold: 0.95, + }, + }); + + const prompts = [ + "What is the capital of France?", + "What is the capital of Japan?", + "What is the capital of Brazil?", + ]; + + for (const prompt of prompts) { + const response = await session.sendAndWait({ prompt }); + if (response) { + console.log(`Q: ${prompt}`); + console.log(`A: ${response.data.content}\n`); + } + } + + console.log("Infinite sessions test complete — all messages processed successfully"); + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/sessions/infinite-sessions/verify.sh b/test/scenarios/sessions/infinite-sessions/verify.sh new file mode 100755 index 000000000..fe4de01e4 --- /dev/null +++ b/test/scenarios/sessions/infinite-sessions/verify.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=120 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -q "Infinite sessions test complete"; then + # Verify all 3 questions got meaningful responses (country/capital names) + if echo "$output" | grep -qiE "France|Japan|Brazil|Paris|Tokyo|Bras[ií]lia"; then + echo "✅ $name passed (infinite sessions confirmed with all responses)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name completed but expected country/capital responses not found" + echo "❌ $name failed (responses missing for some questions)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (incomplete responses)" + fi + else + echo "⚠️ $name ran but completion message not found" + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying sessions/infinite-sessions" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o infinite-sessions-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./infinite-sessions-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/sessions/multi-user-long-lived/README.md b/test/scenarios/sessions/multi-user-long-lived/README.md new file mode 100644 index 000000000..ed911bc21 --- /dev/null +++ b/test/scenarios/sessions/multi-user-long-lived/README.md @@ -0,0 +1,59 @@ +# Multi-User Long-Lived Sessions + +Demonstrates a **production-like multi-user setup** where multiple clients share a single `copilot` server with **persistent, long-lived sessions** stored on disk. + +## Architecture + +``` +┌──────────────────────┐ +│ Copilot CLI │ (headless TCP server) +│ (shared server) │ +└───┬──────┬───────┬───┘ + │ │ │ JSON-RPC over TCP (cliUrl) + │ │ │ +┌───┴──┐ ┌┴────┐ ┌┴─────┐ +│ C1 │ │ C2 │ │ C3 │ +│UserA │ │UserA│ │UserB │ +│Sess1 │ │Sess1│ │Sess2 │ +│ │ │(resume)│ │ +└──────┘ └─────┘ └──────┘ +``` + +## What This Demonstrates + +1. **Shared server** — A single `copilot` instance serves multiple users and sessions over TCP. +2. **Per-user config isolation** — Each user gets their own `configDir` on disk (`tmp/user-a/`, `tmp/user-b/`), so configuration, logs, and state are fully separated. +3. **Session sharing across clients** — User A's Client 1 creates a session and teaches it a fact. Client 2 resumes the same session (by `sessionId`) and retrieves the fact — demonstrating cross-client session continuity. +4. **Session isolation between users** — User B operates in a completely separate session and cannot see User A's conversation history. +5. **Disk persistence** — Session state is written to a real `tmp/` directory, simulating production persistence (cleaned up after the run). + +## What Each Client Does + +| Client | User | Action | +|--------|------|--------| +| **C1** | A | Creates session `user-a-project-session`, teaches it a codename | +| **C2** | A | Resumes `user-a-project-session`, confirms it remembers the codename | +| **C3** | B | Creates separate session `user-b-solo-session`, verifies it has no knowledge of User A's data | + +## Configuration + +| Option | User A | User B | +|--------|--------|--------| +| `cliUrl` | Shared server | Shared server | +| `configDir` | `tmp/user-a/` | `tmp/user-b/` | +| `sessionId` | `user-a-project-session` | `user-b-solo-session` | +| `availableTools` | `[]` | `[]` | + +## When to Use This Pattern + +- **SaaS platforms** — Each tenant gets isolated config and persistent sessions +- **Team collaboration tools** — Multiple team members share sessions on the same project +- **IDE backends** — User opens the same project in multiple editors/tabs + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/sessions/multi-user-long-lived/csharp/Program.cs b/test/scenarios/sessions/multi-user-long-lived/csharp/Program.cs new file mode 100644 index 000000000..a1aaecfc3 --- /dev/null +++ b/test/scenarios/sessions/multi-user-long-lived/csharp/Program.cs @@ -0,0 +1 @@ +Console.WriteLine("SKIP: multi-user-long-lived is not yet implemented for C#"); diff --git a/test/scenarios/sessions/multi-user-long-lived/csharp/csharp.csproj b/test/scenarios/sessions/multi-user-long-lived/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/sessions/multi-user-long-lived/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/sessions/multi-user-long-lived/go/go.mod b/test/scenarios/sessions/multi-user-long-lived/go/go.mod new file mode 100644 index 000000000..25e4f1c56 --- /dev/null +++ b/test/scenarios/sessions/multi-user-long-lived/go/go.mod @@ -0,0 +1,3 @@ +module github.com/github/copilot-sdk/samples/sessions/multi-user-long-lived/go + +go 1.24 diff --git a/test/scenarios/sessions/multi-user-long-lived/go/main.go b/test/scenarios/sessions/multi-user-long-lived/go/main.go new file mode 100644 index 000000000..c4df546a7 --- /dev/null +++ b/test/scenarios/sessions/multi-user-long-lived/go/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("SKIP: multi-user-long-lived is not yet implemented for Go") +} diff --git a/test/scenarios/sessions/multi-user-long-lived/python/main.py b/test/scenarios/sessions/multi-user-long-lived/python/main.py new file mode 100644 index 000000000..ff6c21253 --- /dev/null +++ b/test/scenarios/sessions/multi-user-long-lived/python/main.py @@ -0,0 +1 @@ +print("SKIP: multi-user-long-lived is not yet implemented for Python") diff --git a/test/scenarios/sessions/multi-user-long-lived/python/requirements.txt b/test/scenarios/sessions/multi-user-long-lived/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/sessions/multi-user-long-lived/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/sessions/multi-user-long-lived/typescript/package.json b/test/scenarios/sessions/multi-user-long-lived/typescript/package.json new file mode 100644 index 000000000..55d483f8f --- /dev/null +++ b/test/scenarios/sessions/multi-user-long-lived/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "sessions-multi-user-long-lived-typescript", + "version": "1.0.0", + "private": true, + "description": "Multi-user long-lived sessions — shared server, isolated config, disk persistence", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/sessions/multi-user-long-lived/typescript/src/index.ts b/test/scenarios/sessions/multi-user-long-lived/typescript/src/index.ts new file mode 100644 index 000000000..2071da484 --- /dev/null +++ b/test/scenarios/sessions/multi-user-long-lived/typescript/src/index.ts @@ -0,0 +1,2 @@ +console.log("SKIP: multi-user-long-lived requires memory FS and preset features which is not supported by the old SDK"); +process.exit(0); diff --git a/test/scenarios/sessions/multi-user-long-lived/verify.sh b/test/scenarios/sessions/multi-user-long-lived/verify.sh new file mode 100755 index 000000000..a9e9a6dfb --- /dev/null +++ b/test/scenarios/sessions/multi-user-long-lived/verify.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=120 +SERVER_PID="" +SERVER_PORT_FILE="" + +cleanup() { + if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then + echo "" + echo "Stopping Copilot CLI server (PID $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + [ -n "$SERVER_PORT_FILE" ] && rm -f "$SERVER_PORT_FILE" + # Clean up tmp directories created by the scenario + rm -rf "$SCRIPT_DIR/tmp" 2>/dev/null || true +} +trap cleanup EXIT + +# Resolve Copilot CLI binary: use COPILOT_CLI_PATH env var or find the SDK bundled CLI. +if [ -z "${COPILOT_CLI_PATH:-}" ]; then + # Try to resolve from the TypeScript sample node_modules + TS_DIR="$SCRIPT_DIR/typescript" + if [ -d "$TS_DIR/node_modules/@github/copilot" ]; then + COPILOT_CLI_PATH="$(node -e "console.log(require.resolve('@github/copilot'))" 2>/dev/null || true)" + fi + # Fallback: check PATH + if [ -z "${COPILOT_CLI_PATH:-}" ]; then + COPILOT_CLI_PATH="$(command -v copilot 2>/dev/null || true)" + fi +fi +if [ -z "${COPILOT_CLI_PATH:-}" ]; then + echo "❌ Could not find Copilot CLI binary." + echo " Set COPILOT_CLI_PATH or run: cd typescript && npm install" + exit 1 +fi +echo "Using CLI: $COPILOT_CLI_PATH" + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + echo "$output" + + # Check for multi-user output markers + local has_user_a=false + local has_user_b=false + if echo "$output" | grep -q "User A"; then has_user_a=true; fi + if echo "$output" | grep -q "User B"; then has_user_b=true; fi + + if $has_user_a && $has_user_b; then + echo "✅ $name passed (both users responded)" + PASS=$((PASS + 1)) + elif $has_user_a || $has_user_b; then + echo "⚠️ $name ran but only one user responded" + echo "❌ $name failed (expected both to respond)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (partial)" + else + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Starting Copilot CLI TCP server" +echo "══════════════════════════════════════" +echo "" + +SERVER_PORT_FILE=$(mktemp) +"$COPILOT_CLI_PATH" --headless --auth-token-env GITHUB_TOKEN > "$SERVER_PORT_FILE" 2>&1 & +SERVER_PID=$! + +echo "Waiting for server to be ready..." +PORT="" +for i in $(seq 1 30); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "❌ Server process exited unexpectedly" + cat "$SERVER_PORT_FILE" 2>/dev/null + exit 1 + fi + PORT=$(grep -o 'listening on port [0-9]*' "$SERVER_PORT_FILE" 2>/dev/null | grep -o '[0-9]*' || true) + if [ -n "$PORT" ]; then + break + fi + if [ "$i" -eq 30 ]; then + echo "❌ Server did not announce port within 30 seconds" + exit 1 + fi + sleep 1 +done +export COPILOT_CLI_URL="localhost:$PORT" +echo "Server is ready on port $PORT (PID $SERVER_PID)" +echo "" + +echo "══════════════════════════════════════" +echo " Verifying sessions/multi-user-long-lived" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s)" +echo "══════════════════════════════════════" +echo "" + +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && COPILOT_CLI_URL=$COPILOT_CLI_URL dotnet run --no-build 2>&1" + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/sessions/multi-user-short-lived/README.md b/test/scenarios/sessions/multi-user-short-lived/README.md new file mode 100644 index 000000000..6596fa7bb --- /dev/null +++ b/test/scenarios/sessions/multi-user-short-lived/README.md @@ -0,0 +1,62 @@ +# Multi-User Short-Lived Sessions + +Demonstrates a **stateless backend pattern** where multiple users interact with a shared `copilot` server through **ephemeral sessions** that are created and destroyed per request, with per-user virtual filesystems for isolation. + +## Architecture + +``` +┌──────────────────────┐ +│ Copilot CLI │ (headless TCP server) +│ (shared server) │ +└───┬──────┬───────┬───┘ + │ │ │ JSON-RPC over TCP (cliUrl) + │ │ │ +┌───┴──┐ ┌┴────┐ ┌┴─────┐ +│ C1 │ │ C2 │ │ C3 │ +│UserA │ │UserA│ │UserB │ +│(new) │ │(new)│ │(new) │ +└──────┘ └─────┘ └──────┘ + +Each request → new session → destroy after response +Virtual FS per user (in-memory, not shared across users) +``` + +## What This Demonstrates + +1. **Ephemeral sessions** — Each interaction creates a fresh session and destroys it immediately after. No state persists between requests on the server side. +2. **Per-user virtual filesystem** — Custom tools (`write_file`, `read_file`, `list_files`) backed by in-memory Maps. Each user gets their own isolated filesystem instance — User A's files are invisible to User B. +3. **Application-layer state** — While sessions are stateless, the application maintains state (the virtual FS) between requests for the same user. This mirrors real backends where session state lives in your database, not in the LLM session. +4. **Custom tools** — Uses `defineTool` with `availableTools: []` to replace all built-in tools with a controlled virtual filesystem. +5. **Multi-client isolation** — User A's two clients share the same virtual FS (same user), but User B's virtual FS is completely separate. + +## What Each Client Does + +| Client | User | Action | +|--------|------|--------| +| **C1** | A | Creates `notes.md` in User A's virtual FS | +| **C2** | A | Lists files and reads `notes.md` (sees C1's file because same user FS) | +| **C3** | B | Lists files in User B's virtual FS (empty — completely isolated) | + +## Configuration + +| Option | Value | +|--------|-------| +| `cliUrl` | Shared server | +| `availableTools` | `[]` (no built-in tools) | +| `tools` | `[write_file, read_file, list_files]` (per-user virtual FS) | +| `sessionId` | Auto-generated (ephemeral) | + +## When to Use This Pattern + +- **API backends** — Stateless request/response with no session persistence +- **Serverless functions** — Each invocation is independent +- **High-throughput services** — No session overhead between requests +- **Privacy-sensitive apps** — Conversation history never persists + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/sessions/multi-user-short-lived/csharp/Program.cs b/test/scenarios/sessions/multi-user-short-lived/csharp/Program.cs new file mode 100644 index 000000000..aa72abbf4 --- /dev/null +++ b/test/scenarios/sessions/multi-user-short-lived/csharp/Program.cs @@ -0,0 +1 @@ +Console.WriteLine("SKIP: multi-user-short-lived is not yet implemented for C#"); diff --git a/test/scenarios/sessions/multi-user-short-lived/csharp/csharp.csproj b/test/scenarios/sessions/multi-user-short-lived/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/sessions/multi-user-short-lived/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/sessions/multi-user-short-lived/go/go.mod b/test/scenarios/sessions/multi-user-short-lived/go/go.mod new file mode 100644 index 000000000..b93905394 --- /dev/null +++ b/test/scenarios/sessions/multi-user-short-lived/go/go.mod @@ -0,0 +1,3 @@ +module github.com/github/copilot-sdk/samples/sessions/multi-user-short-lived/go + +go 1.24 diff --git a/test/scenarios/sessions/multi-user-short-lived/go/main.go b/test/scenarios/sessions/multi-user-short-lived/go/main.go new file mode 100644 index 000000000..48667b68b --- /dev/null +++ b/test/scenarios/sessions/multi-user-short-lived/go/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("SKIP: multi-user-short-lived is not yet implemented for Go") +} diff --git a/test/scenarios/sessions/multi-user-short-lived/python/main.py b/test/scenarios/sessions/multi-user-short-lived/python/main.py new file mode 100644 index 000000000..c6b21792b --- /dev/null +++ b/test/scenarios/sessions/multi-user-short-lived/python/main.py @@ -0,0 +1 @@ +print("SKIP: multi-user-short-lived is not yet implemented for Python") diff --git a/test/scenarios/sessions/multi-user-short-lived/python/requirements.txt b/test/scenarios/sessions/multi-user-short-lived/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/sessions/multi-user-short-lived/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/sessions/multi-user-short-lived/typescript/package.json b/test/scenarios/sessions/multi-user-short-lived/typescript/package.json new file mode 100644 index 000000000..b9f3bd7c4 --- /dev/null +++ b/test/scenarios/sessions/multi-user-short-lived/typescript/package.json @@ -0,0 +1,19 @@ +{ + "name": "sessions-multi-user-short-lived-typescript", + "version": "1.0.0", + "private": true, + "description": "Multi-user short-lived sessions — ephemeral per-request sessions with virtual FS", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/sessions/multi-user-short-lived/typescript/src/index.ts b/test/scenarios/sessions/multi-user-short-lived/typescript/src/index.ts new file mode 100644 index 000000000..eeaceb458 --- /dev/null +++ b/test/scenarios/sessions/multi-user-short-lived/typescript/src/index.ts @@ -0,0 +1,2 @@ +console.log("SKIP: multi-user-short-lived requires memory FS and preset features which is not supported by the old SDK"); +process.exit(0); diff --git a/test/scenarios/sessions/multi-user-short-lived/verify.sh b/test/scenarios/sessions/multi-user-short-lived/verify.sh new file mode 100755 index 000000000..24f29601d --- /dev/null +++ b/test/scenarios/sessions/multi-user-short-lived/verify.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=120 +SERVER_PID="" +SERVER_PORT_FILE="" + +cleanup() { + if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then + echo "" + echo "Stopping Copilot CLI server (PID $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + [ -n "$SERVER_PORT_FILE" ] && rm -f "$SERVER_PORT_FILE" +} +trap cleanup EXIT + +# Resolve Copilot CLI binary: use COPILOT_CLI_PATH env var or find the SDK bundled CLI. +if [ -z "${COPILOT_CLI_PATH:-}" ]; then + # Try to resolve from the TypeScript sample node_modules + TS_DIR="$SCRIPT_DIR/typescript" + if [ -d "$TS_DIR/node_modules/@github/copilot" ]; then + COPILOT_CLI_PATH="$(node -e "console.log(require.resolve('@github/copilot'))" 2>/dev/null || true)" + fi + # Fallback: check PATH + if [ -z "${COPILOT_CLI_PATH:-}" ]; then + COPILOT_CLI_PATH="$(command -v copilot 2>/dev/null || true)" + fi +fi +if [ -z "${COPILOT_CLI_PATH:-}" ]; then + echo "❌ Could not find Copilot CLI binary." + echo " Set COPILOT_CLI_PATH or run: cd typescript && npm install" + exit 1 +fi +echo "Using CLI: $COPILOT_CLI_PATH" + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + echo "$output" + + local has_user_a=false + local has_user_b=false + if echo "$output" | grep -q "User A"; then has_user_a=true; fi + if echo "$output" | grep -q "User B"; then has_user_b=true; fi + + if $has_user_a && $has_user_b; then + echo "✅ $name passed (both users responded)" + PASS=$((PASS + 1)) + elif $has_user_a || $has_user_b; then + echo "⚠️ $name ran but only one user responded" + echo "❌ $name failed (expected both to respond)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (partial)" + else + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Starting Copilot CLI TCP server" +echo "══════════════════════════════════════" +echo "" + +SERVER_PORT_FILE=$(mktemp) +"$COPILOT_CLI_PATH" --headless --auth-token-env GITHUB_TOKEN > "$SERVER_PORT_FILE" 2>&1 & +SERVER_PID=$! + +echo "Waiting for server to be ready..." +PORT="" +for i in $(seq 1 30); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "❌ Server process exited unexpectedly" + cat "$SERVER_PORT_FILE" 2>/dev/null + exit 1 + fi + PORT=$(grep -o 'listening on port [0-9]*' "$SERVER_PORT_FILE" 2>/dev/null | grep -o '[0-9]*' || true) + if [ -n "$PORT" ]; then + break + fi + if [ "$i" -eq 30 ]; then + echo "❌ Server did not announce port within 30 seconds" + exit 1 + fi + sleep 1 +done +export COPILOT_CLI_URL="localhost:$PORT" +echo "Server is ready on port $PORT (PID $SERVER_PID)" +echo "" + +echo "══════════════════════════════════════" +echo " Verifying sessions/multi-user-short-lived" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s)" +echo "══════════════════════════════════════" +echo "" + +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && COPILOT_CLI_URL=$COPILOT_CLI_URL dotnet run --no-build 2>&1" + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/sessions/session-resume/README.md b/test/scenarios/sessions/session-resume/README.md new file mode 100644 index 000000000..abc47ad09 --- /dev/null +++ b/test/scenarios/sessions/session-resume/README.md @@ -0,0 +1,27 @@ +# Config Sample: Session Resume + +Demonstrates session persistence and resume with the Copilot SDK. This validates that a destroyed session can be resumed by its ID, retaining full conversation history. + +## What Each Sample Does + +1. Creates a session with `availableTools: []` and model `gpt-4.1` +2. Sends: _"Remember this: the secret word is PINEAPPLE."_ +3. Captures the session ID and destroys the session +4. Resumes the session using the same session ID +5. Sends: _"What was the secret word I told you?"_ +6. Prints the response — which should mention **PINEAPPLE** + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `availableTools` | `[]` (empty array) | Keeps the session simple with no tools | +| `model` | `"gpt-4.1"` | Uses GPT-4.1 for both the initial and resumed session | + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/sessions/session-resume/csharp/Program.cs b/test/scenarios/sessions/session-resume/csharp/Program.cs new file mode 100644 index 000000000..743873afe --- /dev/null +++ b/test/scenarios/sessions/session-resume/csharp/Program.cs @@ -0,0 +1,47 @@ +using GitHub.Copilot.SDK; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + // 1. Create a session + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + AvailableTools = new List(), + }); + + // 2. Send the secret word + await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Remember this: the secret word is PINEAPPLE.", + }); + + // 3. Get the session ID + var sessionId = session.SessionId; + + // 4. Resume the session with the same ID + await using var resumed = await client.ResumeSessionAsync(sessionId); + Console.WriteLine("Session resumed"); + + // 5. Ask for the secret word + var response = await resumed.SendAndWaitAsync(new MessageOptions + { + Prompt = "What was the secret word I told you?", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/sessions/session-resume/csharp/csharp.csproj b/test/scenarios/sessions/session-resume/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/sessions/session-resume/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/sessions/session-resume/go/go.mod b/test/scenarios/sessions/session-resume/go/go.mod new file mode 100644 index 000000000..3722b78d2 --- /dev/null +++ b/test/scenarios/sessions/session-resume/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/sessions/session-resume/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/sessions/session-resume/go/go.sum b/test/scenarios/sessions/session-resume/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/sessions/session-resume/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/sessions/session-resume/go/main.go b/test/scenarios/sessions/session-resume/go/main.go new file mode 100644 index 000000000..cf2cb0448 --- /dev/null +++ b/test/scenarios/sessions/session-resume/go/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + // 1. Create a session + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + AvailableTools: []string{}, + }) + if err != nil { + log.Fatal(err) + } + + // 2. Send the secret word + _, err = session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "Remember this: the secret word is PINEAPPLE.", + }) + if err != nil { + log.Fatal(err) + } + + // 3. Get the session ID (don't destroy — resume needs the session to persist) + sessionID := session.SessionID + + // 4. Resume the session with the same ID + resumed, err := client.ResumeSession(ctx, sessionID) + if err != nil { + log.Fatal(err) + } + fmt.Println("Session resumed") + defer resumed.Destroy() + + // 5. Ask for the secret word + response, err := resumed.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What was the secret word I told you?", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/sessions/session-resume/python/main.py b/test/scenarios/sessions/session-resume/python/main.py new file mode 100644 index 000000000..b65370b97 --- /dev/null +++ b/test/scenarios/sessions/session-resume/python/main.py @@ -0,0 +1,46 @@ +import asyncio +import os +from copilot import CopilotClient + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + # 1. Create a session + session = await client.create_session( + { + "model": "claude-haiku-4.5", + "available_tools": [], + } + ) + + # 2. Send the secret word + await session.send_and_wait( + {"prompt": "Remember this: the secret word is PINEAPPLE."} + ) + + # 3. Get the session ID (don't destroy — resume needs the session to persist) + session_id = session.session_id + + # 4. Resume the session with the same ID + resumed = await client.resume_session(session_id) + print("Session resumed") + + # 5. Ask for the secret word + response = await resumed.send_and_wait( + {"prompt": "What was the secret word I told you?"} + ) + + if response: + print(response.data.content) + + await resumed.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/sessions/session-resume/python/requirements.txt b/test/scenarios/sessions/session-resume/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/sessions/session-resume/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/sessions/session-resume/typescript/package.json b/test/scenarios/sessions/session-resume/typescript/package.json new file mode 100644 index 000000000..11dfd6865 --- /dev/null +++ b/test/scenarios/sessions/session-resume/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "sessions-session-resume-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — session persistence and resume", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/sessions/session-resume/typescript/src/index.ts b/test/scenarios/sessions/session-resume/typescript/src/index.ts new file mode 100644 index 000000000..7d08f40ef --- /dev/null +++ b/test/scenarios/sessions/session-resume/typescript/src/index.ts @@ -0,0 +1,46 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + // 1. Create a session + const session = await client.createSession({ + model: "claude-haiku-4.5", + availableTools: [], + }); + + // 2. Send the secret word + await session.sendAndWait({ + prompt: "Remember this: the secret word is PINEAPPLE.", + }); + + // 3. Get the session ID (don't destroy — resume needs the session to persist) + const sessionId = session.sessionId; + + // 4. Resume the session with the same ID + const resumed = await client.resumeSession(sessionId); + console.log("Session resumed"); + + // 5. Ask for the secret word + const response = await resumed.sendAndWait({ + prompt: "What was the secret word I told you?", + }); + + if (response) { + console.log(response.data.content); + } + + await resumed.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/sessions/session-resume/verify.sh b/test/scenarios/sessions/session-resume/verify.sh new file mode 100755 index 000000000..02cc14d5a --- /dev/null +++ b/test/scenarios/sessions/session-resume/verify.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=120 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + # Check that the response mentions the secret word + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -qi "pineapple"; then + # Also verify session resume indication in output + if echo "$output" | grep -qi "session.*resum\|resum.*session\|Session resumed"; then + echo "✅ $name passed (confirmed session resume — found PINEAPPLE and session resume)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name found PINEAPPLE but no session resume indication in output" + echo "❌ $name failed (session resume not confirmed)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (no resume indication)" + fi + else + echo "⚠️ $name ran but response does not mention PINEAPPLE" + echo "❌ $name failed (secret word not recalled)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (PINEAPPLE not found)" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying sessions/session-resume samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o session-resume-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./session-resume-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/sessions/streaming/README.md b/test/scenarios/sessions/streaming/README.md new file mode 100644 index 000000000..377b3670a --- /dev/null +++ b/test/scenarios/sessions/streaming/README.md @@ -0,0 +1,24 @@ +# Config Sample: Streaming + +Demonstrates configuring the Copilot SDK with **`streaming: true`** to receive incremental response chunks. This validates that the server sends multiple `assistant.message_delta` events before the final `assistant.message` event. + +## What Each Sample Does + +1. Creates a session with `streaming: true` +2. Registers an event listener to count `assistant.message_delta` events +3. Sends: _"What is the capital of France?"_ +4. Prints the final response and the number of streaming chunks received + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `streaming` | `true` | Enables incremental streaming — the server emits `assistant.message_delta` events as tokens are generated | + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/sessions/streaming/csharp/Program.cs b/test/scenarios/sessions/streaming/csharp/Program.cs new file mode 100644 index 000000000..b7c1e0ff5 --- /dev/null +++ b/test/scenarios/sessions/streaming/csharp/Program.cs @@ -0,0 +1,49 @@ +using GitHub.Copilot.SDK; + +var options = new CopilotClientOptions +{ + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}; + +var cliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); +if (!string.IsNullOrEmpty(cliPath)) +{ + options.CliPath = cliPath; +} + +using var client = new CopilotClient(options); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + Streaming = true, + }); + + var chunkCount = 0; + using var subscription = session.On(evt => + { + if (evt is AssistantMessageDeltaEvent) + { + chunkCount++; + } + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response != null) + { + Console.WriteLine(response.Data.Content); + } + Console.WriteLine($"\nStreaming chunks received: {chunkCount}"); +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/sessions/streaming/csharp/csharp.csproj b/test/scenarios/sessions/streaming/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/sessions/streaming/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/sessions/streaming/go/go.mod b/test/scenarios/sessions/streaming/go/go.mod new file mode 100644 index 000000000..acb516379 --- /dev/null +++ b/test/scenarios/sessions/streaming/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/sessions/streaming/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/sessions/streaming/go/go.sum b/test/scenarios/sessions/streaming/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/sessions/streaming/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/sessions/streaming/go/main.go b/test/scenarios/sessions/streaming/go/main.go new file mode 100644 index 000000000..0f55ece43 --- /dev/null +++ b/test/scenarios/sessions/streaming/go/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + Streaming: true, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + chunkCount := 0 + session.On(func(event copilot.SessionEvent) { + if event.Type == "assistant.message_delta" { + chunkCount++ + } + }) + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } + fmt.Printf("\nStreaming chunks received: %d\n", chunkCount) +} diff --git a/test/scenarios/sessions/streaming/python/main.py b/test/scenarios/sessions/streaming/python/main.py new file mode 100644 index 000000000..2bbc94e78 --- /dev/null +++ b/test/scenarios/sessions/streaming/python/main.py @@ -0,0 +1,42 @@ +import asyncio +import os +from copilot import CopilotClient + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session( + { + "model": "claude-haiku-4.5", + "streaming": True, + } + ) + + chunk_count = 0 + + def on_event(event): + nonlocal chunk_count + if event.type.value == "assistant.message_delta": + chunk_count += 1 + + session.on(on_event) + + response = await session.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response: + print(response.data.content) + print(f"\nStreaming chunks received: {chunk_count}") + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/sessions/streaming/python/requirements.txt b/test/scenarios/sessions/streaming/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/sessions/streaming/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/sessions/streaming/typescript/package.json b/test/scenarios/sessions/streaming/typescript/package.json new file mode 100644 index 000000000..4418925d4 --- /dev/null +++ b/test/scenarios/sessions/streaming/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "sessions-streaming-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — streaming response chunks", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/sessions/streaming/typescript/src/index.ts b/test/scenarios/sessions/streaming/typescript/src/index.ts new file mode 100644 index 000000000..fb0a23bed --- /dev/null +++ b/test/scenarios/sessions/streaming/typescript/src/index.ts @@ -0,0 +1,38 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + streaming: true, + }); + + let chunkCount = 0; + session.on("assistant.message_delta", () => { + chunkCount++; + }); + + const response = await session.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response) { + console.log(response.data.content); + } + console.log(`\nStreaming chunks received: ${chunkCount}`); + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/sessions/streaming/verify.sh b/test/scenarios/sessions/streaming/verify.sh new file mode 100755 index 000000000..070ef059b --- /dev/null +++ b/test/scenarios/sessions/streaming/verify.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -qE "Streaming chunks received: [1-9]"; then + # Also verify a final response was received (content printed before chunk count) + if echo "$output" | grep -qiE "Paris|France|capital"; then + echo "✅ $name passed (confirmed streaming chunks and final response)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name had streaming chunks but no final response content detected" + echo "❌ $name failed (final response not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (no final response)" + fi + else + echo "⚠️ $name ran but response may not confirm streaming" + echo "❌ $name failed (expected streaming chunk pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying sessions/streaming samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o streaming-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./streaming-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/tools/custom-agents/README.md b/test/scenarios/tools/custom-agents/README.md new file mode 100644 index 000000000..41bb78c9e --- /dev/null +++ b/test/scenarios/tools/custom-agents/README.md @@ -0,0 +1,32 @@ +# Config Sample: Custom Agents + +Demonstrates configuring the Copilot SDK with **custom agent definitions** that restrict which tools an agent can use. This validates: + +1. **Agent definition** — The `customAgents` session config accepts agent definitions with name, description, tool lists, and custom prompts. +2. **Tool scoping** — Each custom agent can be restricted to a subset of available tools (e.g. read-only tools like `grep`, `glob`, `view`). +3. **Agent awareness** — The model recognizes and can describe the configured custom agents. + +## What Each Sample Does + +1. Creates a session with a `customAgents` array containing a "researcher" agent +2. The researcher agent is scoped to read-only tools: `grep`, `glob`, `view` +3. Sends: _"What custom agents are available? Describe the researcher agent and its capabilities."_ +4. Prints the response — which should describe the researcher agent and its tool restrictions + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `customAgents[0].name` | `"researcher"` | Internal identifier for the agent | +| `customAgents[0].displayName` | `"Research Agent"` | Human-readable name | +| `customAgents[0].description` | Custom text | Describes agent purpose | +| `customAgents[0].tools` | `["grep", "glob", "view"]` | Restricts agent to read-only tools | +| `customAgents[0].prompt` | Custom text | Sets agent behavior instructions | + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/tools/custom-agents/csharp/Program.cs b/test/scenarios/tools/custom-agents/csharp/Program.cs new file mode 100644 index 000000000..394de465f --- /dev/null +++ b/test/scenarios/tools/custom-agents/csharp/Program.cs @@ -0,0 +1,44 @@ +using GitHub.Copilot.SDK; + +var cliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = cliPath, + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + CustomAgents = + [ + new CustomAgentConfig + { + Name = "researcher", + DisplayName = "Research Agent", + Description = "A research agent that can only read and search files, not modify them", + Tools = ["grep", "glob", "view"], + Prompt = "You are a research assistant. You can search and read files but cannot modify anything. When asked about your capabilities, list the tools you have access to.", + }, + ], + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What custom agents are available? Describe the researcher agent and its capabilities.", + }); + + if (response != null) + { + Console.WriteLine(response.Data.Content); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/tools/custom-agents/csharp/csharp.csproj b/test/scenarios/tools/custom-agents/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/tools/custom-agents/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/tools/custom-agents/go/go.mod b/test/scenarios/tools/custom-agents/go/go.mod new file mode 100644 index 000000000..9acbccb06 --- /dev/null +++ b/test/scenarios/tools/custom-agents/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/tools/custom-agents/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/tools/custom-agents/go/go.sum b/test/scenarios/tools/custom-agents/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/tools/custom-agents/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/tools/custom-agents/go/main.go b/test/scenarios/tools/custom-agents/go/main.go new file mode 100644 index 000000000..321793382 --- /dev/null +++ b/test/scenarios/tools/custom-agents/go/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "researcher", + DisplayName: "Research Agent", + Description: "A research agent that can only read and search files, not modify them", + Tools: []string{"grep", "glob", "view"}, + Prompt: "You are a research assistant. You can search and read files but cannot modify anything. When asked about your capabilities, list the tools you have access to.", + }, + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What custom agents are available? Describe the researcher agent and its capabilities.", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/tools/custom-agents/python/main.py b/test/scenarios/tools/custom-agents/python/main.py new file mode 100644 index 000000000..d4e416716 --- /dev/null +++ b/test/scenarios/tools/custom-agents/python/main.py @@ -0,0 +1,40 @@ +import asyncio +import os +from copilot import CopilotClient + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session( + { + "model": "claude-haiku-4.5", + "custom_agents": [ + { + "name": "researcher", + "display_name": "Research Agent", + "description": "A research agent that can only read and search files, not modify them", + "tools": ["grep", "glob", "view"], + "prompt": "You are a research assistant. You can search and read files but cannot modify anything. When asked about your capabilities, list the tools you have access to.", + }, + ], + } + ) + + response = await session.send_and_wait( + {"prompt": "What custom agents are available? Describe the researcher agent and its capabilities."} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/tools/custom-agents/python/requirements.txt b/test/scenarios/tools/custom-agents/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/tools/custom-agents/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/tools/custom-agents/typescript/package.json b/test/scenarios/tools/custom-agents/typescript/package.json new file mode 100644 index 000000000..abb893d67 --- /dev/null +++ b/test/scenarios/tools/custom-agents/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "tools-custom-agents-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — custom agent definitions with tool scoping", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/tools/custom-agents/typescript/src/index.ts b/test/scenarios/tools/custom-agents/typescript/src/index.ts new file mode 100644 index 000000000..b098bffa8 --- /dev/null +++ b/test/scenarios/tools/custom-agents/typescript/src/index.ts @@ -0,0 +1,40 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + customAgents: [ + { + name: "researcher", + displayName: "Research Agent", + description: "A research agent that can only read and search files, not modify them", + tools: ["grep", "glob", "view"], + prompt: "You are a research assistant. You can search and read files but cannot modify anything. When asked about your capabilities, list the tools you have access to.", + }, + ], + }); + + const response = await session.sendAndWait({ + prompt: "What custom agents are available? Describe the researcher agent and its capabilities.", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/tools/custom-agents/verify.sh b/test/scenarios/tools/custom-agents/verify.sh new file mode 100755 index 000000000..826f9df9d --- /dev/null +++ b/test/scenarios/tools/custom-agents/verify.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + # Check that the response mentions the researcher agent or its tools + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -qi "researcher\|Research"; then + echo "✅ $name passed (confirmed custom agent)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name ran but response may not confirm custom agent" + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying tools/custom-agents samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o custom-agents-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./custom-agents-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/tools/mcp-servers/README.md b/test/scenarios/tools/mcp-servers/README.md new file mode 100644 index 000000000..706e50e9e --- /dev/null +++ b/test/scenarios/tools/mcp-servers/README.md @@ -0,0 +1,42 @@ +# Config Sample: MCP Servers + +Demonstrates configuring the Copilot SDK with **MCP (Model Context Protocol) server** integration. This validates that the SDK correctly passes `mcpServers` configuration to the runtime for connecting to external tool providers via stdio. + +## What Each Sample Does + +1. Checks for `MCP_SERVER_CMD` environment variable +2. If set, configures an MCP server entry of type `stdio` in the session config +3. Creates a session with `availableTools: []` and optionally `mcpServers` +4. Sends: _"What is the capital of France?"_ as a fallback test prompt +5. Prints the response and whether MCP servers were configured + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `mcpServers` | Map of server configs | Connects to external MCP servers that expose tools | +| `mcpServers.*.type` | `"stdio"` | Communicates with the MCP server via stdin/stdout | +| `mcpServers.*.command` | Executable path | The MCP server binary to spawn | +| `mcpServers.*.args` | String array | Arguments passed to the MCP server | +| `availableTools` | `[]` (empty array) | No built-in tools; MCP tools used if available | + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `COPILOT_CLI_PATH` | No | Path to `copilot` binary (auto-detected) | +| `GITHUB_TOKEN` | Yes | GitHub auth token (falls back to `gh auth token`) | +| `MCP_SERVER_CMD` | No | MCP server executable — when set, enables MCP integration | +| `MCP_SERVER_ARGS` | No | Space-separated arguments for the MCP server command | + +## Run + +```bash +# Without MCP server (build + basic integration test) +./verify.sh + +# With a real MCP server +MCP_SERVER_CMD=npx MCP_SERVER_ARGS="@modelcontextprotocol/server-filesystem /tmp" ./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/tools/mcp-servers/csharp/Program.cs b/test/scenarios/tools/mcp-servers/csharp/Program.cs new file mode 100644 index 000000000..1d5acbd2e --- /dev/null +++ b/test/scenarios/tools/mcp-servers/csharp/Program.cs @@ -0,0 +1,66 @@ +using GitHub.Copilot.SDK; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + var mcpServers = new Dictionary(); + var mcpServerCmd = Environment.GetEnvironmentVariable("MCP_SERVER_CMD"); + if (!string.IsNullOrEmpty(mcpServerCmd)) + { + var mcpArgs = Environment.GetEnvironmentVariable("MCP_SERVER_ARGS"); + mcpServers["example"] = new Dictionary + { + { "type", "stdio" }, + { "command", mcpServerCmd }, + { "args", string.IsNullOrEmpty(mcpArgs) ? Array.Empty() : mcpArgs.Split(' ') }, + }; + } + + var config = new SessionConfig + { + Model = "claude-haiku-4.5", + AvailableTools = new List(), + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Replace, + Content = "You are a helpful assistant. Answer questions concisely.", + }, + }; + + if (mcpServers.Count > 0) + { + config.McpServers = mcpServers; + } + + await using var session = await client.CreateSessionAsync(config); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } + + if (mcpServers.Count > 0) + { + Console.WriteLine($"\nMCP servers configured: {string.Join(", ", mcpServers.Keys)}"); + } + else + { + Console.WriteLine("\nNo MCP servers configured (set MCP_SERVER_CMD to test with a real server)"); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/tools/mcp-servers/csharp/csharp.csproj b/test/scenarios/tools/mcp-servers/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/tools/mcp-servers/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/tools/mcp-servers/go/go.mod b/test/scenarios/tools/mcp-servers/go/go.mod new file mode 100644 index 000000000..4b93e09e7 --- /dev/null +++ b/test/scenarios/tools/mcp-servers/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/tools/mcp-servers/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/tools/mcp-servers/go/go.sum b/test/scenarios/tools/mcp-servers/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/tools/mcp-servers/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/tools/mcp-servers/go/main.go b/test/scenarios/tools/mcp-servers/go/main.go new file mode 100644 index 000000000..15ffa4c41 --- /dev/null +++ b/test/scenarios/tools/mcp-servers/go/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "strings" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + // MCP server config — demonstrates the configuration pattern. + // When MCP_SERVER_CMD is set, connects to a real MCP server. + // Otherwise, runs without MCP tools as a build/integration test. + mcpServers := map[string]copilot.MCPServerConfig{} + if cmd := os.Getenv("MCP_SERVER_CMD"); cmd != "" { + var args []string + if argsStr := os.Getenv("MCP_SERVER_ARGS"); argsStr != "" { + args = strings.Split(argsStr, " ") + } + mcpServers["example"] = copilot.MCPServerConfig{ + "type": "stdio", + "command": cmd, + "args": args, + } + } + + sessionConfig := &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: "You are a helpful assistant. Answer questions concisely.", + }, + AvailableTools: []string{}, + } + if len(mcpServers) > 0 { + sessionConfig.MCPServers = mcpServers + } + + session, err := client.CreateSession(ctx, sessionConfig) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } + + if len(mcpServers) > 0 { + keys := make([]string, 0, len(mcpServers)) + for k := range mcpServers { + keys = append(keys, k) + } + fmt.Printf("\nMCP servers configured: %s\n", strings.Join(keys, ", ")) + } else { + fmt.Println("\nNo MCP servers configured (set MCP_SERVER_CMD to test with a real server)") + } +} diff --git a/test/scenarios/tools/mcp-servers/python/main.py b/test/scenarios/tools/mcp-servers/python/main.py new file mode 100644 index 000000000..81d2e39ba --- /dev/null +++ b/test/scenarios/tools/mcp-servers/python/main.py @@ -0,0 +1,55 @@ +import asyncio +import os +from copilot import CopilotClient + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + # MCP server config — demonstrates the configuration pattern. + # When MCP_SERVER_CMD is set, connects to a real MCP server. + # Otherwise, runs without MCP tools as a build/integration test. + mcp_servers = {} + if os.environ.get("MCP_SERVER_CMD"): + args = os.environ.get("MCP_SERVER_ARGS", "").split() if os.environ.get("MCP_SERVER_ARGS") else [] + mcp_servers["example"] = { + "type": "stdio", + "command": os.environ["MCP_SERVER_CMD"], + "args": args, + } + + session_config = { + "model": "claude-haiku-4.5", + "available_tools": [], + "system_message": { + "mode": "replace", + "content": "You are a helpful assistant. Answer questions concisely.", + }, + } + if mcp_servers: + session_config["mcp_servers"] = mcp_servers + + session = await client.create_session(session_config) + + response = await session.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response: + print(response.data.content) + + if mcp_servers: + print(f"\nMCP servers configured: {', '.join(mcp_servers.keys())}") + else: + print("\nNo MCP servers configured (set MCP_SERVER_CMD to test with a real server)") + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/tools/mcp-servers/python/requirements.txt b/test/scenarios/tools/mcp-servers/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/tools/mcp-servers/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/tools/mcp-servers/typescript/package.json b/test/scenarios/tools/mcp-servers/typescript/package.json new file mode 100644 index 000000000..eaf810cee --- /dev/null +++ b/test/scenarios/tools/mcp-servers/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "tools-mcp-servers-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — MCP server integration", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/tools/mcp-servers/typescript/src/index.ts b/test/scenarios/tools/mcp-servers/typescript/src/index.ts new file mode 100644 index 000000000..41afa5837 --- /dev/null +++ b/test/scenarios/tools/mcp-servers/typescript/src/index.ts @@ -0,0 +1,55 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + // MCP server config — demonstrates the configuration pattern. + // When MCP_SERVER_CMD is set, connects to a real MCP server. + // Otherwise, runs without MCP tools as a build/integration test. + const mcpServers: Record = {}; + if (process.env.MCP_SERVER_CMD) { + mcpServers["example"] = { + type: "stdio", + command: process.env.MCP_SERVER_CMD, + args: process.env.MCP_SERVER_ARGS ? process.env.MCP_SERVER_ARGS.split(" ") : [], + }; + } + + const session = await client.createSession({ + model: "claude-haiku-4.5", + ...(Object.keys(mcpServers).length > 0 && { mcpServers }), + availableTools: [], + systemMessage: { + mode: "replace", + content: "You are a helpful assistant. Answer questions concisely.", + }, + }); + + const response = await session.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response) { + console.log(response.data.content); + } + + if (Object.keys(mcpServers).length > 0) { + console.log("\nMCP servers configured: " + Object.keys(mcpServers).join(", ")); + } else { + console.log("\nNo MCP servers configured (set MCP_SERVER_CMD to test with a real server)"); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/tools/mcp-servers/verify.sh b/test/scenarios/tools/mcp-servers/verify.sh new file mode 100755 index 000000000..b087e0625 --- /dev/null +++ b/test/scenarios/tools/mcp-servers/verify.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + if [ "$code" -eq 0 ] && [ -n "$output" ] && echo "$output" | grep -qi "MCP\|mcp\|capital\|France\|Paris\|configured"; then + echo "✅ $name passed (got meaningful response)" + PASS=$((PASS + 1)) + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + elif [ "$code" -eq 0 ]; then + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying tools/mcp-servers samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o mcp-servers-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./mcp-servers-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/tools/no-tools/README.md b/test/scenarios/tools/no-tools/README.md new file mode 100644 index 000000000..3cfac6baa --- /dev/null +++ b/test/scenarios/tools/no-tools/README.md @@ -0,0 +1,28 @@ +# Config Sample: No Tools + +Demonstrates configuring the Copilot SDK with **zero tools** and a custom system prompt that reflects the tool-less state. This validates two things: + +1. **Tool removal** — Setting `availableTools: []` removes all built-in tools (bash, view, edit, grep, glob, etc.) from the agent's capabilities. +2. **Agent awareness** — The replaced system prompt tells the agent it has no tools, and the agent's response confirms this. + +## What Each Sample Does + +1. Creates a session with `availableTools: []` and a `systemMessage` in `replace` mode +2. Sends: _"What tools do you have available? List them."_ +3. Prints the response — which should confirm the agent has no tools + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `availableTools` | `[]` (empty array) | Whitelists zero tools — all built-in tools are removed | +| `systemMessage.mode` | `"replace"` | Replaces the default system prompt entirely | +| `systemMessage.content` | Custom minimal prompt | Tells the agent it has no tools and can only respond with text | + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/tools/no-tools/csharp/Program.cs b/test/scenarios/tools/no-tools/csharp/Program.cs new file mode 100644 index 000000000..d25b57a6c --- /dev/null +++ b/test/scenarios/tools/no-tools/csharp/Program.cs @@ -0,0 +1,44 @@ +using GitHub.Copilot.SDK; + +const string SystemPrompt = """ + You are a minimal assistant with no tools available. + You cannot execute code, read files, edit files, search, or perform any actions. + You can only respond with text based on your training data. + If asked about your capabilities or tools, clearly state that you have no tools available. + """; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Replace, + Content = SystemPrompt, + }, + AvailableTools = [], + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the bash tool to run 'echo hello'.", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/tools/no-tools/csharp/csharp.csproj b/test/scenarios/tools/no-tools/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/tools/no-tools/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/tools/no-tools/go/go.mod b/test/scenarios/tools/no-tools/go/go.mod new file mode 100644 index 000000000..74131d3e6 --- /dev/null +++ b/test/scenarios/tools/no-tools/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/tools/no-tools/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/tools/no-tools/go/go.sum b/test/scenarios/tools/no-tools/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/tools/no-tools/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/tools/no-tools/go/main.go b/test/scenarios/tools/no-tools/go/main.go new file mode 100644 index 000000000..75cfa894d --- /dev/null +++ b/test/scenarios/tools/no-tools/go/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +const systemPrompt = `You are a minimal assistant with no tools available. +You cannot execute code, read files, edit files, search, or perform any actions. +You can only respond with text based on your training data. +If asked about your capabilities or tools, clearly state that you have no tools available.` + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: systemPrompt, + }, + AvailableTools: []string{}, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "Use the bash tool to run 'echo hello'.", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/tools/no-tools/python/main.py b/test/scenarios/tools/no-tools/python/main.py new file mode 100644 index 000000000..d857183c0 --- /dev/null +++ b/test/scenarios/tools/no-tools/python/main.py @@ -0,0 +1,38 @@ +import asyncio +import os +from copilot import CopilotClient + +SYSTEM_PROMPT = """You are a minimal assistant with no tools available. +You cannot execute code, read files, edit files, search, or perform any actions. +You can only respond with text based on your training data. +If asked about your capabilities or tools, clearly state that you have no tools available.""" + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session( + { + "model": "claude-haiku-4.5", + "system_message": {"mode": "replace", "content": SYSTEM_PROMPT}, + "available_tools": [], + } + ) + + response = await session.send_and_wait( + {"prompt": "Use the bash tool to run 'echo hello'."} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/tools/no-tools/python/requirements.txt b/test/scenarios/tools/no-tools/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/tools/no-tools/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/tools/no-tools/typescript/package.json b/test/scenarios/tools/no-tools/typescript/package.json new file mode 100644 index 000000000..7c78e51ca --- /dev/null +++ b/test/scenarios/tools/no-tools/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "tools-no-tools-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — no tools, minimal system prompt", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/tools/no-tools/typescript/src/index.ts b/test/scenarios/tools/no-tools/typescript/src/index.ts new file mode 100644 index 000000000..dea9c4f14 --- /dev/null +++ b/test/scenarios/tools/no-tools/typescript/src/index.ts @@ -0,0 +1,38 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +const SYSTEM_PROMPT = `You are a minimal assistant with no tools available. +You cannot execute code, read files, edit files, search, or perform any actions. +You can only respond with text based on your training data. +If asked about your capabilities or tools, clearly state that you have no tools available.`; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + systemMessage: { mode: "replace", content: SYSTEM_PROMPT }, + availableTools: [], + }); + + const response = await session.sendAndWait({ + prompt: "Use the bash tool to run 'echo hello'.", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/tools/no-tools/verify.sh b/test/scenarios/tools/no-tools/verify.sh new file mode 100755 index 000000000..1223c7dcc --- /dev/null +++ b/test/scenarios/tools/no-tools/verify.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + # Check that the response indicates no tools are available + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -qi "no tool\|can't\|cannot\|unable\|don't have\|do not have\|not available"; then + echo "✅ $name passed (confirmed no tools)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name ran but response may not confirm tool-less state" + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying tools/no-tools samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o no-tools-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./no-tools-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/tools/skills/README.md b/test/scenarios/tools/skills/README.md new file mode 100644 index 000000000..138dee2d0 --- /dev/null +++ b/test/scenarios/tools/skills/README.md @@ -0,0 +1,45 @@ +# Config Sample: Skills (SKILL.md Discovery) + +Demonstrates configuring the Copilot SDK with **skill directories** that contain `SKILL.md` files. The agent discovers and uses skills defined in these markdown files at runtime. + +## What This Tests + +1. **Skill discovery** — Setting `skillDirectories` points the agent to directories containing `SKILL.md` files that define available skills. +2. **Skill execution** — The agent reads the skill definition and follows its instructions when prompted to use the skill. +3. **SKILL.md format** — Skills are defined as markdown files with a name, description, and usage instructions. + +## SKILL.md Format + +A `SKILL.md` file is a markdown document placed in a named directory under a skills root: + +``` +sample-skills/ +└── greeting/ + └── SKILL.md # Defines the "greeting" skill +``` + +The file contains: +- **Title** (`# skill-name`) — The skill's identifier +- **Description** — What the skill does +- **Usage** — Instructions the agent follows when the skill is invoked + +## What Each Sample Does + +1. Creates a session with `skillDirectories` pointing to `sample-skills/` +2. Sends: _"Use the greeting skill to greet someone named Alice."_ +3. The agent discovers the greeting skill from `SKILL.md` and generates a personalized greeting +4. Prints the response and confirms skill directory configuration + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `skillDirectories` | `["path/to/sample-skills"]` | Points the agent to directories containing skill definitions | + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/tools/skills/csharp/Program.cs b/test/scenarios/tools/skills/csharp/Program.cs new file mode 100644 index 000000000..fc31c2940 --- /dev/null +++ b/test/scenarios/tools/skills/csharp/Program.cs @@ -0,0 +1,43 @@ +using GitHub.Copilot.SDK; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + var skillsDir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "sample-skills")); + + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + SkillDirectories = [skillsDir], + OnPermissionRequest = (request, invocation) => + Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + Hooks = new SessionHooks + { + OnPreToolUse = (input, invocation) => + Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }), + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the greeting skill to greet someone named Alice.", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } + + Console.WriteLine("\nSkill directories configured successfully"); +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/tools/skills/csharp/csharp.csproj b/test/scenarios/tools/skills/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/tools/skills/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/tools/skills/go/go.mod b/test/scenarios/tools/skills/go/go.mod new file mode 100644 index 000000000..1467fd64f --- /dev/null +++ b/test/scenarios/tools/skills/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/tools/skills/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/tools/skills/go/go.sum b/test/scenarios/tools/skills/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/tools/skills/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/tools/skills/go/main.go b/test/scenarios/tools/skills/go/main.go new file mode 100644 index 000000000..d0d9f8700 --- /dev/null +++ b/test/scenarios/tools/skills/go/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "runtime" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + _, thisFile, _, _ := runtime.Caller(0) + skillsDir := filepath.Join(filepath.Dir(thisFile), "..", "sample-skills") + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + SkillDirectories: []string{skillsDir}, + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: "approved"}, nil + }, + Hooks: &copilot.SessionHooks{ + OnPreToolUse: func(input copilot.PreToolUseHookInput, invocation copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { + return &copilot.PreToolUseHookOutput{PermissionDecision: "allow"}, nil + }, + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "Use the greeting skill to greet someone named Alice.", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } + + fmt.Println("\nSkill directories configured successfully") +} diff --git a/test/scenarios/tools/skills/python/main.py b/test/scenarios/tools/skills/python/main.py new file mode 100644 index 000000000..5adb74b76 --- /dev/null +++ b/test/scenarios/tools/skills/python/main.py @@ -0,0 +1,42 @@ +import asyncio +import os +from pathlib import Path + +from copilot import CopilotClient + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + skills_dir = str(Path(__file__).resolve().parent.parent / "sample-skills") + + session = await client.create_session( + { + "model": "claude-haiku-4.5", + "skill_directories": [skills_dir], + "on_permission_request": lambda _: {"kind": "approved"}, + "hooks": { + "on_pre_tool_use": lambda _: {"permission_decision": "allow"}, + }, + } + ) + + response = await session.send_and_wait( + {"prompt": "Use the greeting skill to greet someone named Alice."} + ) + + if response: + print(response.data.content) + + print("\nSkill directories configured successfully") + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/tools/skills/python/requirements.txt b/test/scenarios/tools/skills/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/tools/skills/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/tools/skills/sample-skills/greeting/SKILL.md b/test/scenarios/tools/skills/sample-skills/greeting/SKILL.md new file mode 100644 index 000000000..feb816c84 --- /dev/null +++ b/test/scenarios/tools/skills/sample-skills/greeting/SKILL.md @@ -0,0 +1,8 @@ +# greeting + +A skill that generates personalized greetings. + +## Usage + +When asked to greet someone, generate a warm, personalized greeting message. +Always include the person's name and a fun fact about their name. diff --git a/test/scenarios/tools/skills/typescript/package.json b/test/scenarios/tools/skills/typescript/package.json new file mode 100644 index 000000000..77d8142b3 --- /dev/null +++ b/test/scenarios/tools/skills/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "tools-skills-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — skill discovery and execution via SKILL.md", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/tools/skills/typescript/src/index.ts b/test/scenarios/tools/skills/typescript/src/index.ts new file mode 100644 index 000000000..fa4b33727 --- /dev/null +++ b/test/scenarios/tools/skills/typescript/src/index.ts @@ -0,0 +1,44 @@ +import { CopilotClient } from "@github/copilot-sdk"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const skillsDir = path.resolve(__dirname, "../../sample-skills"); + + const session = await client.createSession({ + model: "claude-haiku-4.5", + skillDirectories: [skillsDir], + onPermissionRequest: async () => ({ kind: "approved" as const }), + hooks: { + onPreToolUse: async () => ({ permissionDecision: "allow" as const }), + }, + }); + + const response = await session.sendAndWait({ + prompt: "Use the greeting skill to greet someone named Alice.", + }); + + if (response) { + console.log(response.data.content); + } + + console.log("\nSkill directories configured successfully"); + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/tools/skills/verify.sh b/test/scenarios/tools/skills/verify.sh new file mode 100755 index 000000000..fb13fcb16 --- /dev/null +++ b/test/scenarios/tools/skills/verify.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=120 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -qi "skill\|Skill\|greeting\|Alice"; then + echo "✅ $name passed (confirmed skill execution)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name ran but response may not confirm skill execution" + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying tools/skills samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o skills-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./skills-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/tools/tool-filtering/README.md b/test/scenarios/tools/tool-filtering/README.md new file mode 100644 index 000000000..cb664a479 --- /dev/null +++ b/test/scenarios/tools/tool-filtering/README.md @@ -0,0 +1,38 @@ +# Config Sample: Tool Filtering + +Demonstrates advanced tool filtering using the `availableTools` whitelist. This restricts the agent to only the specified read-only tools, removing all others (bash, edit, create_file, etc.). + +The Copilot SDK supports two complementary filtering mechanisms: + +- **`availableTools`** (whitelist) — Only the listed tools are available. All others are removed. +- **`excludedTools`** (blacklist) — All tools are available *except* the listed ones. + +This sample tests the **whitelist** approach with `["grep", "glob", "view"]`. + +## What Each Sample Does + +1. Creates a session with `availableTools: ["grep", "glob", "view"]` and a `systemMessage` in `replace` mode +2. Sends: _"What tools do you have available? List each one by name."_ +3. Prints the response — which should list only grep, glob, and view + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `availableTools` | `["grep", "glob", "view"]` | Whitelists only read-only tools | +| `systemMessage.mode` | `"replace"` | Replaces the default system prompt entirely | +| `systemMessage.content` | Custom prompt | Instructs the agent to list its available tools | + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. + +## Verification + +The verify script checks that: +- The response mentions at least one whitelisted tool (grep, glob, or view) +- The response does **not** mention excluded tools (bash, edit, or create_file) diff --git a/test/scenarios/tools/tool-filtering/csharp/Program.cs b/test/scenarios/tools/tool-filtering/csharp/Program.cs new file mode 100644 index 000000000..dfe3b5a93 --- /dev/null +++ b/test/scenarios/tools/tool-filtering/csharp/Program.cs @@ -0,0 +1,37 @@ +using GitHub.Copilot.SDK; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + SystemMessage = new SystemMessageConfig + { + Mode = SystemMessageMode.Replace, + Content = "You are a helpful assistant. You have access to a limited set of tools. When asked about your tools, list exactly which tools you have available.", + }, + AvailableTools = ["grep", "glob", "view"], + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What tools do you have available? List each one by name.", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/tools/tool-filtering/csharp/csharp.csproj b/test/scenarios/tools/tool-filtering/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/tools/tool-filtering/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/tools/tool-filtering/go/go.mod b/test/scenarios/tools/tool-filtering/go/go.mod new file mode 100644 index 000000000..c3051c52b --- /dev/null +++ b/test/scenarios/tools/tool-filtering/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/tools/tool-filtering/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/tools/tool-filtering/go/go.sum b/test/scenarios/tools/tool-filtering/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/tools/tool-filtering/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/tools/tool-filtering/go/main.go b/test/scenarios/tools/tool-filtering/go/main.go new file mode 100644 index 000000000..3c31c198e --- /dev/null +++ b/test/scenarios/tools/tool-filtering/go/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +const systemPrompt = `You are a helpful assistant. You have access to a limited set of tools. When asked about your tools, list exactly which tools you have available.` + +func main() { + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: systemPrompt, + }, + AvailableTools: []string{"grep", "glob", "view"}, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What tools do you have available? List each one by name.", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/tools/tool-filtering/python/main.py b/test/scenarios/tools/tool-filtering/python/main.py new file mode 100644 index 000000000..174be620e --- /dev/null +++ b/test/scenarios/tools/tool-filtering/python/main.py @@ -0,0 +1,35 @@ +import asyncio +import os +from copilot import CopilotClient + +SYSTEM_PROMPT = """You are a helpful assistant. You have access to a limited set of tools. When asked about your tools, list exactly which tools you have available.""" + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session( + { + "model": "claude-haiku-4.5", + "system_message": {"mode": "replace", "content": SYSTEM_PROMPT}, + "available_tools": ["grep", "glob", "view"], + } + ) + + response = await session.send_and_wait( + {"prompt": "What tools do you have available? List each one by name."} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/tools/tool-filtering/python/requirements.txt b/test/scenarios/tools/tool-filtering/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/tools/tool-filtering/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/tools/tool-filtering/typescript/package.json b/test/scenarios/tools/tool-filtering/typescript/package.json new file mode 100644 index 000000000..5ff9537f8 --- /dev/null +++ b/test/scenarios/tools/tool-filtering/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "tools-tool-filtering-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — advanced tool filtering with availableTools whitelist", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/tools/tool-filtering/typescript/src/index.ts b/test/scenarios/tools/tool-filtering/typescript/src/index.ts new file mode 100644 index 000000000..40cc91124 --- /dev/null +++ b/test/scenarios/tools/tool-filtering/typescript/src/index.ts @@ -0,0 +1,36 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + systemMessage: { + mode: "replace", + content: "You are a helpful assistant. You have access to a limited set of tools. When asked about your tools, list exactly which tools you have available.", + }, + availableTools: ["grep", "glob", "view"], + }); + + const response = await session.sendAndWait({ + prompt: "What tools do you have available? List each one by name.", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/tools/tool-filtering/verify.sh b/test/scenarios/tools/tool-filtering/verify.sh new file mode 100755 index 000000000..058b7129e --- /dev/null +++ b/test/scenarios/tools/tool-filtering/verify.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + # Check that whitelisted tools are mentioned and blacklisted tools are NOT + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + local has_whitelisted=false + local has_blacklisted=false + + if echo "$output" | grep -qi "grep\|glob\|view"; then + has_whitelisted=true + fi + if echo "$output" | grep -qiw "bash\|edit\|create_file"; then + has_blacklisted=true + fi + + if $has_whitelisted && ! $has_blacklisted; then + echo "✅ $name passed (confirmed whitelisted tools only)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name ran but response mentions excluded tools or missing whitelisted tools" + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying tools/tool-filtering samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o tool-filtering-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./tool-filtering-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/tools/virtual-filesystem/README.md b/test/scenarios/tools/virtual-filesystem/README.md new file mode 100644 index 000000000..30665c97b --- /dev/null +++ b/test/scenarios/tools/virtual-filesystem/README.md @@ -0,0 +1,48 @@ +# Config Sample: Virtual Filesystem + +Demonstrates running the Copilot agent with **custom tool implementations backed by an in-memory store** instead of the real filesystem. The agent doesn't know it's virtual — it sees `create_file`, `read_file`, and `list_files` tools that work normally, but zero bytes ever touch disk. + +This pattern is the foundation for: +- **WASM / browser agents** where there's no real filesystem +- **Cloud-hosted sandboxes** where file ops go to object storage +- **Multi-tenant platforms** where each user gets isolated virtual storage +- **Office add-ins** where "files" are document sections in memory + +## How It Works + +1. **Disable all built-in tools** with `availableTools: []` +2. **Provide custom tools** (`create_file`, `read_file`, `list_files`) whose handlers read/write a `Map` / `dict` / `HashMap` in the host process +3. **Auto-approve permissions** — no dialogs since the tools are entirely user-controlled +4. The agent uses the tools normally — it doesn't know they're virtual + +## What Each Sample Does + +1. Creates a session with no built-in tools + 3 custom virtual FS tools +2. Sends: _"Create a file called plan.md with a brief 3-item project plan for building a CLI tool. Then read it back and tell me what you wrote."_ +3. The agent calls `create_file` → writes to in-memory map +4. The agent calls `read_file` → reads from in-memory map +5. Prints the agent's response +6. Dumps the in-memory store to prove files exist only in memory + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `availableTools` | `[]` (empty) | Removes all built-in tools (bash, view, edit, create_file, grep, glob, etc.) | +| `tools` | `[create_file, read_file, list_files]` | Custom tools backed by in-memory storage | +| `onPermissionRequest` | Auto-approve | No permission dialogs | +| `hooks.onPreToolUse` | Auto-allow | No tool confirmation prompts | + +## Key Insight + +The integrator controls the tool layer. By replacing built-in tools with custom implementations, you can swap the backing store to anything — `Map`, Redis, S3, SQLite, IndexedDB — without the agent knowing or caring. The system prompt stays the same. The agent plans and operates normally. + +Custom tools with the same name as a built-in automatically override the built-in — no need to explicitly exclude them. `availableTools: []` removes all built-ins while keeping your custom tools available. + +## Run + +```bash +./verify.sh +``` + +Requires the `copilot` binary (auto-detected or set `COPILOT_CLI_PATH`) and `GITHUB_TOKEN`. diff --git a/test/scenarios/tools/virtual-filesystem/csharp/Program.cs b/test/scenarios/tools/virtual-filesystem/csharp/Program.cs new file mode 100644 index 000000000..4018b5f99 --- /dev/null +++ b/test/scenarios/tools/virtual-filesystem/csharp/Program.cs @@ -0,0 +1,81 @@ +using System.ComponentModel; +using GitHub.Copilot.SDK; +using Microsoft.Extensions.AI; + +// In-memory virtual filesystem +var virtualFs = new Dictionary(); + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + AvailableTools = [], + Tools = + [ + AIFunctionFactory.Create( + ([Description("File path")] string path, [Description("File content")] string content) => + { + virtualFs[path] = content; + return $"Created {path} ({content.Length} bytes)"; + }, + "create_file", + "Create or overwrite a file at the given path with the provided content"), + AIFunctionFactory.Create( + ([Description("File path")] string path) => + { + return virtualFs.TryGetValue(path, out var content) + ? content + : $"Error: file not found: {path}"; + }, + "read_file", + "Read the contents of a file at the given path"), + AIFunctionFactory.Create( + () => + { + return virtualFs.Count == 0 + ? "No files" + : string.Join("\n", virtualFs.Keys); + }, + "list_files", + "List all files in the virtual filesystem"), + ], + OnPermissionRequest = (request, invocation) => + Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + Hooks = new SessionHooks + { + OnPreToolUse = (input, invocation) => + Task.FromResult(new PreToolUseHookOutput { PermissionDecision = "allow" }), + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Create a file called plan.md with a brief 3-item project plan for building a CLI tool. Then read it back and tell me what you wrote.", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } + + // Dump the virtual filesystem to prove nothing touched disk + Console.WriteLine("\n--- Virtual filesystem contents ---"); + foreach (var (path, content) in virtualFs) + { + Console.WriteLine($"\n[{path}]"); + Console.WriteLine(content); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/tools/virtual-filesystem/csharp/csharp.csproj b/test/scenarios/tools/virtual-filesystem/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/tools/virtual-filesystem/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/tools/virtual-filesystem/go/go.mod b/test/scenarios/tools/virtual-filesystem/go/go.mod new file mode 100644 index 000000000..d6606bb7b --- /dev/null +++ b/test/scenarios/tools/virtual-filesystem/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/tools/virtual-filesystem/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/tools/virtual-filesystem/go/go.sum b/test/scenarios/tools/virtual-filesystem/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/tools/virtual-filesystem/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/tools/virtual-filesystem/go/main.go b/test/scenarios/tools/virtual-filesystem/go/main.go new file mode 100644 index 000000000..625d999ea --- /dev/null +++ b/test/scenarios/tools/virtual-filesystem/go/main.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "strings" + "sync" + + copilot "github.com/github/copilot-sdk/go" +) + +// In-memory virtual filesystem +var ( + virtualFs = make(map[string]string) + virtualFsMu sync.Mutex +) + +type CreateFileArgs struct { + Path string `json:"path" description:"File path"` + Content string `json:"content" description:"File content"` +} + +type ReadFileArgs struct { + Path string `json:"path" description:"File path"` +} + +func main() { + createFile := copilot.DefineTool[CreateFileArgs, string]( + "create_file", + "Create or overwrite a file at the given path with the provided content", + func(args CreateFileArgs, inv copilot.ToolInvocation) (string, error) { + virtualFsMu.Lock() + virtualFs[args.Path] = args.Content + virtualFsMu.Unlock() + return fmt.Sprintf("Created %s (%d bytes)", args.Path, len(args.Content)), nil + }, + ) + + readFile := copilot.DefineTool[ReadFileArgs, string]( + "read_file", + "Read the contents of a file at the given path", + func(args ReadFileArgs, inv copilot.ToolInvocation) (string, error) { + virtualFsMu.Lock() + content, ok := virtualFs[args.Path] + virtualFsMu.Unlock() + if !ok { + return fmt.Sprintf("Error: file not found: %s", args.Path), nil + } + return content, nil + }, + ) + + listFiles := copilot.Tool{ + Name: "list_files", + Description: "List all files in the virtual filesystem", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{}, + }, + Handler: func(inv copilot.ToolInvocation) (copilot.ToolResult, error) { + virtualFsMu.Lock() + defer virtualFsMu.Unlock() + if len(virtualFs) == 0 { + return copilot.ToolResult{TextResultForLLM: "No files"}, nil + } + paths := make([]string, 0, len(virtualFs)) + for p := range virtualFs { + paths = append(paths, p) + } + return copilot.ToolResult{TextResultForLLM: strings.Join(paths, "\n")}, nil + }, + } + + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + // Remove all built-in tools — only our custom virtual FS tools are available + AvailableTools: []string{}, + Tools: []copilot.Tool{createFile, readFile, listFiles}, + OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: "approved"}, nil + }, + Hooks: &copilot.SessionHooks{ + OnPreToolUse: func(input copilot.PreToolUseHookInput, inv copilot.HookInvocation) (*copilot.PreToolUseHookOutput, error) { + return &copilot.PreToolUseHookOutput{PermissionDecision: "allow"}, nil + }, + }, + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "Create a file called plan.md with a brief 3-item project plan " + + "for building a CLI tool. Then read it back and tell me what you wrote.", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } + + // Dump the virtual filesystem to prove nothing touched disk + fmt.Println("\n--- Virtual filesystem contents ---") + for path, content := range virtualFs { + fmt.Printf("\n[%s]\n", path) + fmt.Println(content) + } +} diff --git a/test/scenarios/tools/virtual-filesystem/python/main.py b/test/scenarios/tools/virtual-filesystem/python/main.py new file mode 100644 index 000000000..b150c1a2a --- /dev/null +++ b/test/scenarios/tools/virtual-filesystem/python/main.py @@ -0,0 +1,88 @@ +import asyncio +import os +from copilot import CopilotClient, define_tool +from pydantic import BaseModel, Field + +# In-memory virtual filesystem +virtual_fs: dict[str, str] = {} + + +class CreateFileParams(BaseModel): + path: str = Field(description="File path") + content: str = Field(description="File content") + + +class ReadFileParams(BaseModel): + path: str = Field(description="File path") + + +@define_tool(description="Create or overwrite a file at the given path with the provided content") +def create_file(params: CreateFileParams) -> str: + virtual_fs[params.path] = params.content + return f"Created {params.path} ({len(params.content)} bytes)" + + +@define_tool(description="Read the contents of a file at the given path") +def read_file(params: ReadFileParams) -> str: + content = virtual_fs.get(params.path) + if content is None: + return f"Error: file not found: {params.path}" + return content + + +@define_tool(description="List all files in the virtual filesystem") +def list_files() -> str: + if not virtual_fs: + return "No files" + return "\n".join(virtual_fs.keys()) + + +async def auto_approve_permission(request, invocation): + return {"kind": "approved"} + + +async def auto_approve_tool(input_data, invocation): + return {"permissionDecision": "allow"} + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session( + { + "model": "claude-haiku-4.5", + "available_tools": [], + "tools": [create_file, read_file, list_files], + "on_permission_request": auto_approve_permission, + "hooks": {"on_pre_tool_use": auto_approve_tool}, + } + ) + + response = await session.send_and_wait( + { + "prompt": ( + "Create a file called plan.md with a brief 3-item project plan " + "for building a CLI tool. Then read it back and tell me what you wrote." + ) + } + ) + + if response: + print(response.data.content) + + # Dump the virtual filesystem to prove nothing touched disk + print("\n--- Virtual filesystem contents ---") + for path, content in virtual_fs.items(): + print(f"\n[{path}]") + print(content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/tools/virtual-filesystem/python/requirements.txt b/test/scenarios/tools/virtual-filesystem/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/tools/virtual-filesystem/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/tools/virtual-filesystem/typescript/package.json b/test/scenarios/tools/virtual-filesystem/typescript/package.json new file mode 100644 index 000000000..9f1415d83 --- /dev/null +++ b/test/scenarios/tools/virtual-filesystem/typescript/package.json @@ -0,0 +1,19 @@ +{ + "name": "tools-virtual-filesystem-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — virtual filesystem sandbox with auto-approved permissions", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0" + } +} diff --git a/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts b/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts new file mode 100644 index 000000000..0a6f0ffd1 --- /dev/null +++ b/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts @@ -0,0 +1,86 @@ +import { CopilotClient, defineTool } from "@github/copilot-sdk"; +import { z } from "zod"; + +// In-memory virtual filesystem +const virtualFs = new Map(); + +const createFile = defineTool("create_file", { + description: "Create or overwrite a file at the given path with the provided content", + parameters: z.object({ + path: z.string().describe("File path"), + content: z.string().describe("File content"), + }), + handler: async (args) => { + virtualFs.set(args.path, args.content); + return `Created ${args.path} (${args.content.length} bytes)`; + }, +}); + +const readFile = defineTool("read_file", { + description: "Read the contents of a file at the given path", + parameters: z.object({ + path: z.string().describe("File path"), + }), + handler: async (args) => { + const content = virtualFs.get(args.path); + if (content === undefined) return `Error: file not found: ${args.path}`; + return content; + }, +}); + +const listFiles = defineTool("list_files", { + description: "List all files in the virtual filesystem", + parameters: z.object({}), + handler: async () => { + if (virtualFs.size === 0) return "No files"; + return [...virtualFs.keys()].join("\n"); + }, +}); + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { + cliPath: process.env.COPILOT_CLI_PATH, + }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ + model: "claude-haiku-4.5", + // Remove all built-in tools — only our custom virtual FS tools are available + availableTools: [], + tools: [createFile, readFile, listFiles], + onPermissionRequest: async () => ({ kind: "approved" as const }), + hooks: { + onPreToolUse: async () => ({ permissionDecision: "allow" as const }), + }, + }); + + const response = await session.sendAndWait({ + prompt: + "Create a file called plan.md with a brief 3-item project plan for building a CLI tool. " + + "Then read it back and tell me what you wrote.", + }); + + if (response) { + console.log(response.data.content); + } + + // Dump the virtual filesystem to prove nothing touched disk + console.log("\n--- Virtual filesystem contents ---"); + for (const [path, content] of virtualFs) { + console.log(`\n[${path}]`); + console.log(content); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/tools/virtual-filesystem/verify.sh b/test/scenarios/tools/virtual-filesystem/verify.sh new file mode 100755 index 000000000..30fd1fd37 --- /dev/null +++ b/test/scenarios/tools/virtual-filesystem/verify.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=120 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + + echo "$output" + + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -qi "Virtual filesystem contents" && echo "$output" | grep -qi "plan\.md"; then + echo "✅ $name passed (virtual FS operations confirmed)" + PASS=$((PASS + 1)) + else + echo "❌ $name failed (expected pattern not found)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + elif [ "$code" -eq 124 ]; then + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying tools/virtual-filesystem" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o virtual-filesystem-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./virtual-filesystem-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/transport/README.md b/test/scenarios/transport/README.md new file mode 100644 index 000000000..d986cc7ad --- /dev/null +++ b/test/scenarios/transport/README.md @@ -0,0 +1,36 @@ +# Transport Samples + +Minimal samples organized by **transport model** — the wire protocol used to communicate with `copilot`. Each subfolder demonstrates one transport with the same "What is the capital of France?" flow. + +## Transport Models + +| Transport | Description | Languages | +|-----------|-------------|-----------| +| **[stdio](stdio/)** | SDK spawns `copilot` as a child process and communicates via stdin/stdout | TypeScript, Python, Go | +| **[tcp](tcp/)** | SDK connects to a pre-running `copilot` TCP server | TypeScript, Python, Go | +| **[wasm](wasm/)** | SDK loads `copilot` as an in-process WASM module | TypeScript | + +## How They Differ + +| | stdio | tcp | wasm | +|---|---|---|---| +| **Process model** | Child process | External server | In-process | +| **Binary required** | Yes (auto-spawned) | Yes (pre-started) | No (WASM module) | +| **Wire protocol** | Content-Length framed JSON-RPC over pipes | Content-Length framed JSON-RPC over TCP | In-memory function calls | +| **Best for** | CLI tools, desktop apps | Shared servers, multi-tenant | Serverless, edge, sandboxed | + +## Prerequisites + +- **Authentication** — set `GITHUB_TOKEN`, or run `gh auth login` +- **Copilot CLI** — required for stdio and tcp (set `COPILOT_CLI_PATH`) +- Language toolchains as needed (Node.js 20+, Python 3.10+, Go 1.24+) + +## Verification + +Each transport has its own `verify.sh` that builds and runs all language samples: + +```bash +cd stdio && ./verify.sh +cd tcp && ./verify.sh +cd wasm && ./verify.sh +``` diff --git a/test/scenarios/transport/reconnect/README.md b/test/scenarios/transport/reconnect/README.md new file mode 100644 index 000000000..4ae3c22d2 --- /dev/null +++ b/test/scenarios/transport/reconnect/README.md @@ -0,0 +1,63 @@ +# TCP Reconnection Sample + +Tests that a **pre-running** `copilot` TCP server correctly handles **multiple sequential sessions**. The SDK connects, creates a session, exchanges a message, destroys the session, then repeats the process — verifying the server remains responsive across session lifecycles. + +``` +┌─────────────┐ TCP (JSON-RPC) ┌──────────────┐ +│ Your App │ ─────────────────▶ │ Copilot CLI │ +│ (SDK) │ ◀───────────────── │ (TCP server) │ +└─────────────┘ └──────────────┘ + Session 1: create → send → destroy + Session 2: create → send → destroy +``` + +## What This Tests + +- The TCP server accepts a new session after a previous session is destroyed +- Server state is properly cleaned up between sessions +- The SDK client can reuse the same connection for multiple session lifecycles +- No resource leaks or port conflicts across sequential sessions + +## Languages + +| Directory | SDK / Approach | Language | +|-----------|---------------|----------| +| `typescript/` | `@github/copilot-sdk` | TypeScript (Node.js) | + +> **TypeScript-only:** This scenario tests SDK-level session lifecycle over TCP. The reconnection behavior is an SDK concern, so only one language is needed to verify it. + +## Prerequisites + +- **Copilot CLI** — set `COPILOT_CLI_PATH` +- **Authentication** — set `GITHUB_TOKEN`, or run `gh auth login` +- **Node.js 20+** (TypeScript sample) + +## Quick Start + +Start the TCP server: + +```bash +copilot --port 3000 --headless --auth-token-env GITHUB_TOKEN +``` + +Run the sample: + +```bash +cd typescript +npm install && npm run build +COPILOT_CLI_URL=localhost:3000 npm start +``` + +## Verification + +```bash +./verify.sh +``` + +Runs in three phases: + +1. **Server** — starts `copilot` as a TCP server (auto-detects port) +2. **Build** — installs dependencies and compiles the TypeScript sample +3. **E2E Run** — executes the sample with a 120-second timeout, verifies both sessions complete and prints "Reconnect test passed" + +The server is automatically stopped when the script exits. diff --git a/test/scenarios/transport/reconnect/csharp/Program.cs b/test/scenarios/transport/reconnect/csharp/Program.cs new file mode 100644 index 000000000..a93ed8a71 --- /dev/null +++ b/test/scenarios/transport/reconnect/csharp/Program.cs @@ -0,0 +1,61 @@ +using GitHub.Copilot.SDK; + +var cliUrl = Environment.GetEnvironmentVariable("COPILOT_CLI_URL") ?? "localhost:3000"; + +using var client = new CopilotClient(new CopilotClientOptions { CliUrl = cliUrl }); +await client.StartAsync(); + +try +{ + // First session + Console.WriteLine("--- Session 1 ---"); + await using var session1 = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + }); + + var response1 = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response1?.Data?.Content != null) + { + Console.WriteLine(response1.Data.Content); + } + else + { + Console.Error.WriteLine("No response content received for session 1"); + Environment.Exit(1); + } + Console.WriteLine("Session 1 destroyed\n"); + + // Second session — tests that the server accepts new sessions + Console.WriteLine("--- Session 2 ---"); + await using var session2 = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + }); + + var response2 = await session2.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response2?.Data?.Content != null) + { + Console.WriteLine(response2.Data.Content); + } + else + { + Console.Error.WriteLine("No response content received for session 2"); + Environment.Exit(1); + } + Console.WriteLine("Session 2 destroyed"); + + Console.WriteLine("\nReconnect test passed — both sessions completed successfully"); +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/transport/reconnect/csharp/csharp.csproj b/test/scenarios/transport/reconnect/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/transport/reconnect/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/transport/reconnect/go/go.mod b/test/scenarios/transport/reconnect/go/go.mod new file mode 100644 index 000000000..7a1f80d6c --- /dev/null +++ b/test/scenarios/transport/reconnect/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/transport/reconnect/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/transport/reconnect/go/go.sum b/test/scenarios/transport/reconnect/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/transport/reconnect/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/transport/reconnect/go/main.go b/test/scenarios/transport/reconnect/go/main.go new file mode 100644 index 000000000..27f6c1592 --- /dev/null +++ b/test/scenarios/transport/reconnect/go/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + cliUrl := os.Getenv("COPILOT_CLI_URL") + if cliUrl == "" { + cliUrl = "localhost:3000" + } + + client := copilot.NewClient(&copilot.ClientOptions{ + CLIUrl: cliUrl, + }) + + ctx := context.Background() + + // Session 1 + fmt.Println("--- Session 1 ---") + session1, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + }) + if err != nil { + log.Fatal(err) + } + + response1, err := session1.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response1 != nil && response1.Data.Content != nil { + fmt.Println(*response1.Data.Content) + } else { + log.Fatal("No response content received for session 1") + } + + session1.Destroy() + fmt.Println("Session 1 destroyed") + fmt.Println() + + // Session 2 — tests that the server accepts new sessions + fmt.Println("--- Session 2 ---") + session2, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + }) + if err != nil { + log.Fatal(err) + } + + response2, err := session2.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response2 != nil && response2.Data.Content != nil { + fmt.Println(*response2.Data.Content) + } else { + log.Fatal("No response content received for session 2") + } + + session2.Destroy() + fmt.Println("Session 2 destroyed") + + fmt.Println("\nReconnect test passed — both sessions completed successfully") +} diff --git a/test/scenarios/transport/reconnect/python/main.py b/test/scenarios/transport/reconnect/python/main.py new file mode 100644 index 000000000..e8aecea50 --- /dev/null +++ b/test/scenarios/transport/reconnect/python/main.py @@ -0,0 +1,52 @@ +import asyncio +import os +import sys +from copilot import CopilotClient + + +async def main(): + client = CopilotClient({ + "cli_url": os.environ.get("COPILOT_CLI_URL", "localhost:3000"), + }) + + try: + # First session + print("--- Session 1 ---") + session1 = await client.create_session({"model": "claude-haiku-4.5"}) + + response1 = await session1.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response1 and response1.data.content: + print(response1.data.content) + else: + print("No response content received for session 1", file=sys.stderr) + sys.exit(1) + + await session1.destroy() + print("Session 1 destroyed\n") + + # Second session — tests that the server accepts new sessions + print("--- Session 2 ---") + session2 = await client.create_session({"model": "claude-haiku-4.5"}) + + response2 = await session2.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response2 and response2.data.content: + print(response2.data.content) + else: + print("No response content received for session 2", file=sys.stderr) + sys.exit(1) + + await session2.destroy() + print("Session 2 destroyed") + + print("\nReconnect test passed — both sessions completed successfully") + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/transport/reconnect/python/requirements.txt b/test/scenarios/transport/reconnect/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/transport/reconnect/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/transport/reconnect/typescript/package.json b/test/scenarios/transport/reconnect/typescript/package.json new file mode 100644 index 000000000..9ef9163ca --- /dev/null +++ b/test/scenarios/transport/reconnect/typescript/package.json @@ -0,0 +1,19 @@ +{ + "name": "transport-reconnect-typescript", + "version": "1.0.0", + "private": true, + "description": "Transport sample — TCP reconnection and session reuse", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.5.0" + } +} diff --git a/test/scenarios/transport/reconnect/typescript/src/index.ts b/test/scenarios/transport/reconnect/typescript/src/index.ts new file mode 100644 index 000000000..57bac483d --- /dev/null +++ b/test/scenarios/transport/reconnect/typescript/src/index.ts @@ -0,0 +1,54 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + cliUrl: process.env.COPILOT_CLI_URL || "localhost:3000", + }); + + try { + // First session + console.log("--- Session 1 ---"); + const session1 = await client.createSession({ model: "claude-haiku-4.5" }); + + const response1 = await session1.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response1?.data.content) { + console.log(response1.data.content); + } else { + console.error("No response content received for session 1"); + process.exit(1); + } + + await session1.destroy(); + console.log("Session 1 destroyed\n"); + + // Second session — tests that the server accepts new sessions + console.log("--- Session 2 ---"); + const session2 = await client.createSession({ model: "claude-haiku-4.5" }); + + const response2 = await session2.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response2?.data.content) { + console.log(response2.data.content); + } else { + console.error("No response content received for session 2"); + process.exit(1); + } + + await session2.destroy(); + console.log("Session 2 destroyed"); + + console.log("\nReconnect test passed — both sessions completed successfully"); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/transport/reconnect/verify.sh b/test/scenarios/transport/reconnect/verify.sh new file mode 100755 index 000000000..28dd7326f --- /dev/null +++ b/test/scenarios/transport/reconnect/verify.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=120 +SERVER_PID="" +SERVER_PORT_FILE="" + +cleanup() { + if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then + echo "" + echo "Stopping Copilot CLI server (PID $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + [ -n "$SERVER_PORT_FILE" ] && rm -f "$SERVER_PORT_FILE" +} +trap cleanup EXIT + +# Resolve Copilot CLI binary: use COPILOT_CLI_PATH env var or find the SDK bundled CLI. +if [ -z "${COPILOT_CLI_PATH:-}" ]; then + # Try to resolve from the TypeScript sample node_modules + TS_DIR="$SCRIPT_DIR/typescript" + if [ -d "$TS_DIR/node_modules/@github/copilot" ]; then + COPILOT_CLI_PATH="$(node -e "console.log(require.resolve('@github/copilot'))" 2>/dev/null || true)" + fi + # Fallback: check PATH + if [ -z "${COPILOT_CLI_PATH:-}" ]; then + COPILOT_CLI_PATH="$(command -v copilot 2>/dev/null || true)" + fi +fi +if [ -z "${COPILOT_CLI_PATH:-}" ]; then + echo "❌ Could not find Copilot CLI binary." + echo " Set COPILOT_CLI_PATH or run: cd typescript && npm install" + exit 1 +fi +echo "Using CLI: $COPILOT_CLI_PATH" + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + if [ "$code" -eq 0 ] && echo "$output" | grep -q "Reconnect test passed"; then + echo "$output" + echo "✅ $name passed (reconnect verified)" + PASS=$((PASS + 1)) + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Starting Copilot CLI TCP server" +echo "══════════════════════════════════════" +echo "" + +SERVER_PORT_FILE=$(mktemp) +"$COPILOT_CLI_PATH" --headless --auth-token-env GITHUB_TOKEN > "$SERVER_PORT_FILE" 2>&1 & +SERVER_PID=$! + +# Wait for server to announce its port +echo "Waiting for server to be ready..." +PORT="" +for i in $(seq 1 30); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "❌ Server process exited unexpectedly" + cat "$SERVER_PORT_FILE" 2>/dev/null + exit 1 + fi + PORT=$(grep -o 'listening on port [0-9]*' "$SERVER_PORT_FILE" 2>/dev/null | grep -o '[0-9]*' || true) + if [ -n "$PORT" ]; then + break + fi + if [ "$i" -eq 30 ]; then + echo "❌ Server did not announce port within 30 seconds" + exit 1 + fi + sleep 1 +done +export COPILOT_CLI_URL="localhost:$PORT" +echo "Server is ready on port $PORT (PID $SERVER_PID)" +echo "" + +echo "══════════════════════════════════════" +echo " Verifying transport/reconnect" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o reconnect-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && CLI_URL=$COPILOT_CLI_URL node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && CLI_URL=$COPILOT_CLI_URL python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && CLI_URL=$COPILOT_CLI_URL ./reconnect-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && COPILOT_CLI_URL=$COPILOT_CLI_URL dotnet run --no-build 2>&1" + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/transport/stdio/README.md b/test/scenarios/transport/stdio/README.md new file mode 100644 index 000000000..5178935cc --- /dev/null +++ b/test/scenarios/transport/stdio/README.md @@ -0,0 +1,65 @@ +# Stdio Transport Samples + +Samples demonstrating the **stdio** transport model. The SDK spawns `copilot` as a child process and communicates over standard input/output using Content-Length-framed JSON-RPC 2.0 messages. + +``` +┌─────────────┐ stdin/stdout (JSON-RPC) ┌──────────────┐ +│ Your App │ ──────────────────────────▶ │ Copilot CLI │ +│ (SDK) │ ◀────────────────────────── │ (child proc) │ +└─────────────┘ └──────────────┘ +``` + +Each sample follows the same flow: + +1. **Create a client** that spawns `copilot` automatically +2. **Open a session** targeting the `gpt-4.1` model +3. **Send a prompt** ("What is the capital of France?") +4. **Print the response** and clean up + +## Languages + +| Directory | SDK / Approach | Language | +|-----------|---------------|----------| +| `typescript/` | `@github/copilot-sdk` | TypeScript (Node.js) | +| `python/` | `github-copilot-sdk` | Python | +| `go/` | `github.com/github/copilot-sdk/go` | Go | + +## Prerequisites + +- **Copilot CLI** — set `COPILOT_CLI_PATH` +- **Authentication** — set `GITHUB_TOKEN`, or run `gh auth login` +- **Node.js 20+** (TypeScript sample) +- **Python 3.10+** (Python sample) +- **Go 1.24+** (Go sample) + +## Quick Start + +**TypeScript** +```bash +cd typescript +npm install && npm run build && npm start +``` + +**Python** +```bash +cd python +pip install -r requirements.txt +python main.py +``` + +**Go** +```bash +cd go +go run main.go +``` + +## Verification + +```bash +./verify.sh +``` + +Runs in two phases: + +1. **Build** — installs dependencies and compiles each sample +2. **E2E Run** — executes each sample with a 60-second timeout and verifies it produces output diff --git a/test/scenarios/transport/stdio/csharp/Program.cs b/test/scenarios/transport/stdio/csharp/Program.cs new file mode 100644 index 000000000..50505b776 --- /dev/null +++ b/test/scenarios/transport/stdio/csharp/Program.cs @@ -0,0 +1,31 @@ +using GitHub.Copilot.SDK; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), + GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/transport/stdio/csharp/csharp.csproj b/test/scenarios/transport/stdio/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/transport/stdio/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/transport/stdio/go/go.mod b/test/scenarios/transport/stdio/go/go.mod new file mode 100644 index 000000000..2dcc35310 --- /dev/null +++ b/test/scenarios/transport/stdio/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/transport/stdio/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/transport/stdio/go/go.sum b/test/scenarios/transport/stdio/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/transport/stdio/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/transport/stdio/go/main.go b/test/scenarios/transport/stdio/go/main.go new file mode 100644 index 000000000..5543f6b4d --- /dev/null +++ b/test/scenarios/transport/stdio/go/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + // Go SDK auto-reads COPILOT_CLI_PATH from env + client := copilot.NewClient(&copilot.ClientOptions{ + GithubToken: os.Getenv("GITHUB_TOKEN"), + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/transport/stdio/python/main.py b/test/scenarios/transport/stdio/python/main.py new file mode 100644 index 000000000..138bb5646 --- /dev/null +++ b/test/scenarios/transport/stdio/python/main.py @@ -0,0 +1,27 @@ +import asyncio +import os +from copilot import CopilotClient + + +async def main(): + opts = {"github_token": os.environ.get("GITHUB_TOKEN")} + if os.environ.get("COPILOT_CLI_PATH"): + opts["cli_path"] = os.environ["COPILOT_CLI_PATH"] + client = CopilotClient(opts) + + try: + session = await client.create_session({"model": "claude-haiku-4.5"}) + + response = await session.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/transport/stdio/python/requirements.txt b/test/scenarios/transport/stdio/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/transport/stdio/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/transport/stdio/typescript/package.json b/test/scenarios/transport/stdio/typescript/package.json new file mode 100644 index 000000000..bd56e8a38 --- /dev/null +++ b/test/scenarios/transport/stdio/typescript/package.json @@ -0,0 +1,19 @@ +{ + "name": "transport-stdio-typescript", + "version": "1.0.0", + "private": true, + "description": "Stdio transport sample — spawns Copilot CLI as a child process", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.5.0" + } +} diff --git a/test/scenarios/transport/stdio/typescript/src/index.ts b/test/scenarios/transport/stdio/typescript/src/index.ts new file mode 100644 index 000000000..989a0b9a6 --- /dev/null +++ b/test/scenarios/transport/stdio/typescript/src/index.ts @@ -0,0 +1,29 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + ...(process.env.COPILOT_CLI_PATH && { cliPath: process.env.COPILOT_CLI_PATH }), + githubToken: process.env.GITHUB_TOKEN, + }); + + try { + const session = await client.createSession({ model: "claude-haiku-4.5" }); + + const response = await session.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response) { + console.log(response.data.content); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/transport/stdio/verify.sh b/test/scenarios/transport/stdio/verify.sh new file mode 100755 index 000000000..9a5b11b17 --- /dev/null +++ b/test/scenarios/transport/stdio/verify.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 + +# COPILOT_CLI_PATH is optional — the SDK discovers the bundled CLI automatically. +# Set it only to override with a custom binary path. +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +fi + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi +echo "" + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + if [ "$code" -eq 0 ] && [ -n "$output" ] && echo "$output" | grep -qi "Paris\|capital\|France\|response"; then + echo "$output" + echo "✅ $name passed (content validated)" + PASS=$((PASS + 1)) + elif [ "$code" -eq 0 ] && [ -n "$output" ]; then + echo "$output" + echo "❌ $name failed (no meaningful content in response)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (no content match)" + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Verifying stdio transport samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o stdio-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./stdio-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/transport/tcp/README.md b/test/scenarios/transport/tcp/README.md new file mode 100644 index 000000000..ea2df27cd --- /dev/null +++ b/test/scenarios/transport/tcp/README.md @@ -0,0 +1,82 @@ +# TCP Transport Samples + +Samples demonstrating the **TCP** transport model. The SDK connects to a **pre-running** `copilot` TCP server using Content-Length-framed JSON-RPC 2.0 messages over a TCP socket. + +``` +┌─────────────┐ TCP (JSON-RPC) ┌──────────────┐ +│ Your App │ ─────────────────▶ │ Copilot CLI │ +│ (SDK) │ ◀───────────────── │ (TCP server) │ +└─────────────┘ └──────────────┘ +``` + +Each sample follows the same flow: + +1. **Connect** to a running `copilot` server via TCP +2. **Open a session** targeting the `gpt-4.1` model +3. **Send a prompt** ("What is the capital of France?") +4. **Print the response** and clean up + +## Languages + +| Directory | SDK / Approach | Language | +|-----------|---------------|----------| +| `typescript/` | `@github/copilot-sdk` | TypeScript (Node.js) | +| `python/` | `github-copilot-sdk` | Python | +| `go/` | `github.com/github/copilot-sdk/go` | Go | + +## Prerequisites + +- **Copilot CLI** — set `COPILOT_CLI_PATH` +- **Authentication** — set `GITHUB_TOKEN`, or run `gh auth login` +- **Node.js 20+** (TypeScript sample) +- **Python 3.10+** (Python sample) +- **Go 1.24+** (Go sample) + +## Starting the Server + +Start `copilot` as a TCP server before running any sample: + +```bash +copilot --port 3000 --headless --auth-token-env GITHUB_TOKEN +``` + +## Quick Start + +**TypeScript** +```bash +cd typescript +npm install && npm run build && npm start +``` + +**Python** +```bash +cd python +pip install -r requirements.txt +python main.py +``` + +**Go** +```bash +cd go +go run main.go +``` + +All samples default to `localhost:3000`. Override with the `COPILOT_CLI_URL` environment variable: + +```bash +COPILOT_CLI_URL=localhost:8080 npm start +``` + +## Verification + +```bash +./verify.sh +``` + +Runs in three phases: + +1. **Server** — starts `copilot` as a TCP server (auto-detects port) +2. **Build** — installs dependencies and compiles each sample +3. **E2E Run** — executes each sample with a 60-second timeout and verifies it produces output + +The server is automatically stopped when the script exits. diff --git a/test/scenarios/transport/tcp/csharp/Program.cs b/test/scenarios/transport/tcp/csharp/Program.cs new file mode 100644 index 000000000..051c877d2 --- /dev/null +++ b/test/scenarios/transport/tcp/csharp/Program.cs @@ -0,0 +1,36 @@ +using GitHub.Copilot.SDK; + +var cliUrl = Environment.GetEnvironmentVariable("COPILOT_CLI_URL") ?? "localhost:3000"; + +using var client = new CopilotClient(new CopilotClientOptions +{ + CliUrl = cliUrl, +}); + +await client.StartAsync(); + +try +{ + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-haiku-4.5", + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "What is the capital of France?", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } + else + { + Console.WriteLine("(no response)"); + } +} +finally +{ + await client.StopAsync(); +} diff --git a/test/scenarios/transport/tcp/csharp/csharp.csproj b/test/scenarios/transport/tcp/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/transport/tcp/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/transport/tcp/go/go.mod b/test/scenarios/transport/tcp/go/go.mod new file mode 100644 index 000000000..dc1a0b6f9 --- /dev/null +++ b/test/scenarios/transport/tcp/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/transport/tcp/go + +go 1.24 + +require github.com/github/copilot-sdk/go v0.0.0 + +require github.com/google/jsonschema-go v0.4.2 // indirect + +replace github.com/github/copilot-sdk/go => ../../../../../go diff --git a/test/scenarios/transport/tcp/go/go.sum b/test/scenarios/transport/tcp/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/transport/tcp/go/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= diff --git a/test/scenarios/transport/tcp/go/main.go b/test/scenarios/transport/tcp/go/main.go new file mode 100644 index 000000000..9a0b1be4e --- /dev/null +++ b/test/scenarios/transport/tcp/go/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + cliUrl := os.Getenv("COPILOT_CLI_URL") + if cliUrl == "" { + cliUrl = "localhost:3000" + } + + client := copilot.NewClient(&copilot.ClientOptions{ + CLIUrl: cliUrl, + }) + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + }) + if err != nil { + log.Fatal(err) + } + defer session.Destroy() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is the capital of France?", + }) + if err != nil { + log.Fatal(err) + } + + if response != nil && response.Data.Content != nil { + fmt.Println(*response.Data.Content) + } +} diff --git a/test/scenarios/transport/tcp/python/main.py b/test/scenarios/transport/tcp/python/main.py new file mode 100644 index 000000000..05aaa9270 --- /dev/null +++ b/test/scenarios/transport/tcp/python/main.py @@ -0,0 +1,26 @@ +import asyncio +import os +from copilot import CopilotClient + + +async def main(): + client = CopilotClient({ + "cli_url": os.environ.get("COPILOT_CLI_URL", "localhost:3000"), + }) + + try: + session = await client.create_session({"model": "claude-haiku-4.5"}) + + response = await session.send_and_wait( + {"prompt": "What is the capital of France?"} + ) + + if response: + print(response.data.content) + + await session.destroy() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/transport/tcp/python/requirements.txt b/test/scenarios/transport/tcp/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/transport/tcp/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/transport/tcp/typescript/package.json b/test/scenarios/transport/tcp/typescript/package.json new file mode 100644 index 000000000..98799b75a --- /dev/null +++ b/test/scenarios/transport/tcp/typescript/package.json @@ -0,0 +1,19 @@ +{ + "name": "transport-tcp-typescript", + "version": "1.0.0", + "private": true, + "description": "TCP transport sample — connects to a running Copilot CLI TCP server", + "type": "module", + "scripts": { + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@github/copilot-sdk": "file:../../../../../nodejs" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.24.0", + "typescript": "^5.5.0" + } +} diff --git a/test/scenarios/transport/tcp/typescript/src/index.ts b/test/scenarios/transport/tcp/typescript/src/index.ts new file mode 100644 index 000000000..139e47a86 --- /dev/null +++ b/test/scenarios/transport/tcp/typescript/src/index.ts @@ -0,0 +1,31 @@ +import { CopilotClient } from "@github/copilot-sdk"; + +async function main() { + const client = new CopilotClient({ + cliUrl: process.env.COPILOT_CLI_URL || "localhost:3000", + }); + + try { + const session = await client.createSession({ model: "claude-haiku-4.5" }); + + const response = await session.sendAndWait({ + prompt: "What is the capital of France?", + }); + + if (response?.data.content) { + console.log(response.data.content); + } else { + console.error("No response content received"); + process.exit(1); + } + + await session.destroy(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/transport/tcp/verify.sh b/test/scenarios/transport/tcp/verify.sh new file mode 100755 index 000000000..711e0959a --- /dev/null +++ b/test/scenarios/transport/tcp/verify.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PASS=0 +FAIL=0 +ERRORS="" +TIMEOUT=60 +SERVER_PID="" +SERVER_PORT_FILE="" + +cleanup() { + if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then + echo "" + echo "Stopping Copilot CLI server (PID $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + [ -n "$SERVER_PORT_FILE" ] && rm -f "$SERVER_PORT_FILE" +} +trap cleanup EXIT + +# Resolve Copilot CLI binary: use COPILOT_CLI_PATH env var or find the SDK bundled CLI. +if [ -z "${COPILOT_CLI_PATH:-}" ]; then + # Try to resolve from the TypeScript sample node_modules + TS_DIR="$SCRIPT_DIR/typescript" + if [ -d "$TS_DIR/node_modules/@github/copilot" ]; then + COPILOT_CLI_PATH="$(node -e "console.log(require.resolve('@github/copilot'))" 2>/dev/null || true)" + fi + # Fallback: check PATH + if [ -z "${COPILOT_CLI_PATH:-}" ]; then + COPILOT_CLI_PATH="$(command -v copilot 2>/dev/null || true)" + fi +fi +if [ -z "${COPILOT_CLI_PATH:-}" ]; then + echo "❌ Could not find Copilot CLI binary." + echo " Set COPILOT_CLI_PATH or run: cd typescript && npm install" + exit 1 +fi +echo "Using CLI: $COPILOT_CLI_PATH" + +# Ensure GITHUB_TOKEN is set for auth +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set and gh auth not available. E2E runs will fail." +fi + +# Use gtimeout on macOS, timeout on Linux +if command -v gtimeout &>/dev/null; then + TIMEOUT_CMD="gtimeout" +elif command -v timeout &>/dev/null; then + TIMEOUT_CMD="timeout" +else + echo "⚠️ No timeout command found. Install coreutils (brew install coreutils)." + echo " Running without timeouts." + TIMEOUT_CMD="" +fi + +check() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + if output=$("$@" 2>&1); then + echo "$output" + echo "✅ $name passed" + PASS=$((PASS + 1)) + else + echo "$output" + echo "❌ $name failed" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +run_with_timeout() { + local name="$1" + shift + printf "━━━ %s ━━━\n" "$name" + local output="" + local code=0 + if [ -n "$TIMEOUT_CMD" ]; then + output=$($TIMEOUT_CMD "$TIMEOUT" "$@" 2>&1) && code=0 || code=$? + else + output=$("$@" 2>&1) && code=0 || code=$? + fi + if [ "$code" -eq 0 ] && [ -n "$output" ] && echo "$output" | grep -qi "Paris\|capital\|France\|response"; then + echo "$output" + echo "✅ $name passed (content validated)" + PASS=$((PASS + 1)) + elif [ "$code" -eq 0 ] && [ -n "$output" ]; then + echo "$output" + echo "❌ $name failed (no meaningful content in response)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (no content match)" + elif [ "$code" -eq 124 ]; then + echo "${output:-(no output)}" + echo "❌ $name failed (timed out after ${TIMEOUT}s)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name (timeout)" + else + echo "${output:-(empty output)}" + echo "❌ $name failed (exit code $code)" + FAIL=$((FAIL + 1)) + ERRORS="$ERRORS\n - $name" + fi + echo "" +} + +echo "══════════════════════════════════════" +echo " Starting Copilot CLI TCP server" +echo "══════════════════════════════════════" +echo "" + +SERVER_PORT_FILE=$(mktemp) +"$COPILOT_CLI_PATH" --headless --auth-token-env GITHUB_TOKEN > "$SERVER_PORT_FILE" 2>&1 & +SERVER_PID=$! + +# Wait for server to announce its port +echo "Waiting for server to be ready..." +PORT="" +for i in $(seq 1 30); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "❌ Server process exited unexpectedly" + cat "$SERVER_PORT_FILE" 2>/dev/null + exit 1 + fi + PORT=$(grep -o 'listening on port [0-9]*' "$SERVER_PORT_FILE" 2>/dev/null | grep -o '[0-9]*' || true) + if [ -n "$PORT" ]; then + break + fi + if [ "$i" -eq 30 ]; then + echo "❌ Server did not announce port within 30 seconds" + exit 1 + fi + sleep 1 +done +export COPILOT_CLI_URL="localhost:$PORT" +echo "Server is ready on port $PORT (PID $SERVER_PID)" +echo "" + +echo "══════════════════════════════════════" +echo " Verifying TCP transport samples" +echo " Phase 1: Build" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: install + compile +check "TypeScript (install)" bash -c "cd '$SCRIPT_DIR/typescript' && npm install --ignore-scripts 2>&1" +check "TypeScript (build)" bash -c "cd '$SCRIPT_DIR/typescript' && npm run build 2>&1" + +# Python: install + syntax +check "Python (install)" bash -c "python3 -c 'import copilot' 2>/dev/null || (cd '$SCRIPT_DIR/python' && pip3 install -r requirements.txt --quiet 2>&1)" +check "Python (syntax)" bash -c "python3 -c \"import ast; ast.parse(open('$SCRIPT_DIR/python/main.py').read()); print('Syntax OK')\"" + +# Go: build +check "Go (build)" bash -c "cd '$SCRIPT_DIR/go' && go build -o tcp-go . 2>&1" + +# C#: build +check "C# (build)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet build --nologo -v q 2>&1" + + +echo "══════════════════════════════════════" +echo " Phase 2: E2E Run (timeout ${TIMEOUT}s each)" +echo "══════════════════════════════════════" +echo "" + +# TypeScript: run +run_with_timeout "TypeScript (run)" bash -c "cd '$SCRIPT_DIR/typescript' && node dist/index.js" + +# Python: run +run_with_timeout "Python (run)" bash -c "cd '$SCRIPT_DIR/python' && python3 main.py" + +# Go: run +run_with_timeout "Go (run)" bash -c "cd '$SCRIPT_DIR/go' && ./tcp-go" + +# C#: run +run_with_timeout "C# (run)" bash -c "cd '$SCRIPT_DIR/csharp' && dotnet run --no-build 2>&1" + + +echo "══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed" +echo "══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then + echo -e "Failures:$ERRORS" + exit 1 +fi diff --git a/test/scenarios/verify.sh b/test/scenarios/verify.sh new file mode 100755 index 000000000..543c93d2b --- /dev/null +++ b/test/scenarios/verify.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +TMP_DIR="$(mktemp -d)" +MAX_PARALLEL="${SCENARIO_PARALLEL:-6}" + +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT + +# ── CLI path (optional) ────────────────────────────────────────────── +if [ -n "${COPILOT_CLI_PATH:-}" ]; then + echo "Using CLI override: $COPILOT_CLI_PATH" +else + echo "No COPILOT_CLI_PATH set — SDKs will use their bundled CLI." +fi + +# ── Auth ──────────────────────────────────────────────────────────── +if [ -z "${GITHUB_TOKEN:-}" ]; then + if command -v gh &>/dev/null; then + export GITHUB_TOKEN=$(gh auth token 2>/dev/null || true) + fi +fi +if [ -z "${GITHUB_TOKEN:-}" ]; then + echo "⚠️ GITHUB_TOKEN not set" +fi + +# ── Pre-install shared dependencies ──────────────────────────────── +# Install Python SDK once to avoid parallel pip install races +if command -v pip3 &>/dev/null; then + pip3 install -e "$ROOT_DIR/python" --quiet 2>/dev/null || true +fi + +# ── Discover verify scripts ──────────────────────────────────────── +VERIFY_SCRIPTS=() +while IFS= read -r script; do + VERIFY_SCRIPTS+=("$script") +done < <(find "$SCRIPT_DIR" -mindepth 3 -maxdepth 3 -name verify.sh -type f | sort) + +TOTAL=${#VERIFY_SCRIPTS[@]} + +# ── SDK icon helpers ──────────────────────────────────────────────── +sdk_icons() { + local log="$1" + local ts py go cs + ts="$(sdk_status "$log" "TypeScript")" + py="$(sdk_status "$log" "Python")" + go="$(sdk_status "$log" "Go ")" + cs="$(sdk_status "$log" "C#")" + printf "TS %s PY %s GO %s C# %s" "$ts" "$py" "$go" "$cs" +} + +sdk_status() { + local log="$1" sdk="$2" + if ! grep -q "$sdk" "$log" 2>/dev/null; then + printf "·"; return + fi + if grep "$sdk" "$log" | grep -q "❌"; then + printf "✗"; return + fi + if grep "$sdk" "$log" | grep -q "⏭\|SKIP"; then + printf "⊘"; return + fi + printf "✓" +} + +# ── Display helpers ───────────────────────────────────────────────── +BOLD="\033[1m" +DIM="\033[2m" +RESET="\033[0m" +RED="\033[31m" +GREEN="\033[32m" +YELLOW="\033[33m" +CYAN="\033[36m" +CLR_LINE="\033[2K" + +BAR_WIDTH=20 + +progress_bar() { + local done_count="$1" total="$2" + local filled=$(( done_count * BAR_WIDTH / total )) + local empty=$(( BAR_WIDTH - filled )) + printf "${DIM}[" + [ "$filled" -gt 0 ] && printf "%0.s█" $(seq 1 "$filled") + [ "$empty" -gt 0 ] && printf "%0.s░" $(seq 1 "$empty") + printf "]${RESET}" +} + +declare -a SCENARIO_NAMES=() +declare -a SCENARIO_STATES=() # waiting | running | done +declare -a SCENARIO_RESULTS=() # "" | PASS | FAIL | SKIP +declare -a SCENARIO_PIDS=() +declare -a SCENARIO_ICONS=() + +for script in "${VERIFY_SCRIPTS[@]}"; do + rel="${script#"$SCRIPT_DIR"/}" + name="${rel%/verify.sh}" + SCENARIO_NAMES+=("$name") + SCENARIO_STATES+=("waiting") + SCENARIO_RESULTS+=("") + SCENARIO_PIDS+=("") + SCENARIO_ICONS+=("") +done + +# ── Execution ─────────────────────────────────────────────────────── +RUNNING_COUNT=0 +NEXT_IDX=0 +PASSED=0; FAILED=0; SKIPPED=0 +DONE_COUNT=0 + +# The progress line is the ONE line we update in-place via \r. +# When a scenario completes, we print its result as a permanent line +# above the progress line. +COLS="${COLUMNS:-$(tput cols 2>/dev/null || echo 80)}" + +print_progress() { + local running_names="" + for i in "${!SCENARIO_STATES[@]}"; do + if [ "${SCENARIO_STATES[$i]}" = "running" ]; then + [ -n "$running_names" ] && running_names="$running_names, " + running_names="$running_names${SCENARIO_NAMES[$i]}" + fi + done + # Build the prefix: " 3/33 [████░░░░░░░░░░░░░░░░] " + local prefix + prefix=$(printf " %d/%d " "$DONE_COUNT" "$TOTAL") + local prefix_len=$(( ${#prefix} + BAR_WIDTH + 4 )) # +4 for []+ spaces + # Truncate running names to fit in one terminal line + local max_names=$(( COLS - prefix_len - 1 )) + if [ "${#running_names}" -gt "$max_names" ] && [ "$max_names" -gt 3 ]; then + running_names="${running_names:0:$((max_names - 1))}…" + fi + printf "\r${CLR_LINE}" + printf "%s" "$prefix" + progress_bar "$DONE_COUNT" "$TOTAL" + printf " ${CYAN}%s${RESET}" "$running_names" +} + +print_result() { + local i="$1" + local name="${SCENARIO_NAMES[$i]}" + local result="${SCENARIO_RESULTS[$i]}" + local icons="${SCENARIO_ICONS[$i]}" + + # Clear the progress line, print result, then reprint progress below + printf "\r${CLR_LINE}" + case "$result" in + PASS) printf " ${GREEN}✅${RESET} %-36s %s\n" "$name" "$icons" ;; + FAIL) printf " ${RED}❌${RESET} %-36s %s\n" "$name" "$icons" ;; + SKIP) printf " ${YELLOW}⏭${RESET} %-36s %s\n" "$name" "$icons" ;; + esac +} + +start_scenario() { + local i="$1" + local script="${VERIFY_SCRIPTS[$i]}" + local name="${SCENARIO_NAMES[$i]}" + local log_file="$TMP_DIR/${name//\//__}.log" + + bash "$script" >"$log_file" 2>&1 & + SCENARIO_PIDS[$i]=$! + SCENARIO_STATES[$i]="running" + RUNNING_COUNT=$((RUNNING_COUNT + 1)) +} + +finish_scenario() { + local i="$1" exit_code="$2" + local name="${SCENARIO_NAMES[$i]}" + local log_file="$TMP_DIR/${name//\//__}.log" + + SCENARIO_STATES[$i]="done" + RUNNING_COUNT=$((RUNNING_COUNT - 1)) + DONE_COUNT=$((DONE_COUNT + 1)) + + if grep -q "^SKIP:" "$log_file" 2>/dev/null; then + SCENARIO_RESULTS[$i]="SKIP" + SKIPPED=$((SKIPPED + 1)) + elif [ "$exit_code" -eq 0 ]; then + SCENARIO_RESULTS[$i]="PASS" + PASSED=$((PASSED + 1)) + else + SCENARIO_RESULTS[$i]="FAIL" + FAILED=$((FAILED + 1)) + fi + + SCENARIO_ICONS[$i]="$(sdk_icons "$log_file")" + print_result "$i" +} + +echo "" + +# Launch initial batch +while [ "$NEXT_IDX" -lt "$TOTAL" ] && [ "$RUNNING_COUNT" -lt "$MAX_PARALLEL" ]; do + start_scenario "$NEXT_IDX" + NEXT_IDX=$((NEXT_IDX + 1)) +done +print_progress + +# Poll for completion and launch new scenarios +while [ "$RUNNING_COUNT" -gt 0 ]; do + for i in "${!SCENARIO_STATES[@]}"; do + if [ "${SCENARIO_STATES[$i]}" = "running" ]; then + pid="${SCENARIO_PIDS[$i]}" + if ! kill -0 "$pid" 2>/dev/null; then + wait "$pid" 2>/dev/null && exit_code=0 || exit_code=$? + finish_scenario "$i" "$exit_code" + + # Launch next if available + if [ "$NEXT_IDX" -lt "$TOTAL" ] && [ "$RUNNING_COUNT" -lt "$MAX_PARALLEL" ]; then + start_scenario "$NEXT_IDX" + NEXT_IDX=$((NEXT_IDX + 1)) + fi + + print_progress + fi + fi + done + sleep 0.2 +done + +# Clear the progress line +printf "\r${CLR_LINE}" +echo "" + +# ── Final summary ────────────────────────────────────────────────── +printf " ${BOLD}%d${RESET} scenarios" "$TOTAL" +[ "$PASSED" -gt 0 ] && printf " ${GREEN}${BOLD}%d passed${RESET}" "$PASSED" +[ "$FAILED" -gt 0 ] && printf " ${RED}${BOLD}%d failed${RESET}" "$FAILED" +[ "$SKIPPED" -gt 0 ] && printf " ${YELLOW}${BOLD}%d skipped${RESET}" "$SKIPPED" +echo "" + +# ── Failed scenario logs ─────────────────────────────────────────── +if [ "$FAILED" -gt 0 ]; then + echo "" + printf "${BOLD}══════════════════════════════════════════════════════════════════════════${RESET}\n" + printf "${RED}${BOLD} Failed Scenario Logs${RESET}\n" + printf "${BOLD}══════════════════════════════════════════════════════════════════════════${RESET}\n" + for i in "${!SCENARIO_NAMES[@]}"; do + if [ "${SCENARIO_RESULTS[$i]}" = "FAIL" ]; then + local_name="${SCENARIO_NAMES[$i]}" + local_log="$TMP_DIR/${local_name//\//__}.log" + echo "" + printf "${RED}━━━ %s ━━━${RESET}\n" "$local_name" + printf " %s\n" "${SCENARIO_ICONS[$i]}" + echo "" + tail -30 "$local_log" | sed 's/^/ /' + fi + done + exit 1 +fi diff --git a/test/snapshots/mcp_and_agents/should_pass_literal_env_values_to_mcp_server_subprocess.yaml b/test/snapshots/mcp_and_agents/should_pass_literal_env_values_to_mcp_server_subprocess.yaml new file mode 100644 index 000000000..29ba0fc68 --- /dev/null +++ b/test/snapshots/mcp_and_agents/should_pass_literal_env_values_to_mcp_server_subprocess.yaml @@ -0,0 +1,21 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the env-echo/get_env tool to read the TEST_SECRET environment variable. Reply with just the value, nothing + else. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: env-echo-get_env + arguments: '{"name":"TEST_SECRET"}' + - role: tool + tool_call_id: toolcall_0 + content: hunter2 + - role: assistant + content: hunter2 diff --git a/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided.yaml b/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided.yaml new file mode 100644 index 000000000..4413bb20a --- /dev/null +++ b/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided.yaml @@ -0,0 +1,49 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Run 'node --version' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Checking Node.js version"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"node --version","description":"Check Node.js version"}' + - messages: + - role: system + content: ${system} + - role: user + content: Run 'node --version' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Checking Node.js version"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"node --version","description":"Check Node.js version"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: Permission denied and could not request permission from user + - role: assistant + content: I received a permission denied error. It appears I don't have permission to execute the `node --version` + command in this environment. This might be due to security restrictions or the command not being available in + the current context. diff --git a/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume.yaml b/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume.yaml new file mode 100644 index 000000000..788a1a783 --- /dev/null +++ b/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume.yaml @@ -0,0 +1,55 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 1+1? + - role: assistant + content: 1+1 = 2 + - role: user + content: Run 'node --version' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Checking Node.js version"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"node --version","description":"Check Node.js version"}' + - messages: + - role: system + content: ${system} + - role: user + content: What is 1+1? + - role: assistant + content: 1+1 = 2 + - role: user + content: Run 'node --version' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Checking Node.js version"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"node --version","description":"Check Node.js version"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: Permission denied and could not request permission from user + - role: assistant + content: Permission was denied to run the command. I don't have access to execute shell commands in this environment.