diff --git a/.github/workflows/scenario-builds.yml b/.github/workflows/scenario-builds.yml new file mode 100644 index 00000000..a66ede5e --- /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 9ec30582..6ff86481 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ # Documentation validation output docs/.validation/ +.DS_Store diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 7a82c8ea..8c70a4a2 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -105,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 UseStdio and CliPath"); + 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)) + { + _options.UseStdio = false; } // Validate auth options with external server diff --git a/go/types.go b/go/types.go index 99bd9c84..6abbf4a1 100644 --- a/go/types.go +++ b/go/types.go @@ -640,7 +640,7 @@ type createSessionRequest struct { 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"` @@ -671,7 +671,7 @@ type resumeSessionRequest struct { 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"` diff --git a/justfile b/justfile index 8cf72c73..5eea5100 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/python/copilot/client.py b/python/copilot/client.py index c27e0af9..90260ffb 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -481,7 +481,7 @@ 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: @@ -652,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") diff --git a/test/scenarios/.gitignore b/test/scenarios/.gitignore new file mode 100644 index 00000000..b56abbd2 --- /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 00000000..e45aac32 --- /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 00000000..5fd4511d --- /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 00000000..6bb9dd23 --- /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 00000000..48e37596 --- /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 00000000..9a727c69 --- /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 00000000..6e171099 --- /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 00000000..a42f90b8 --- /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 00000000..7f5e5834 --- /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 00000000..f9a8f4d6 --- /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 00000000..4bb834ff --- /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 00000000..bd5f30dd --- /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 00000000..24a8c7ca --- /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 00000000..86843355 --- /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 00000000..e6b2789a --- /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 00000000..48e37596 --- /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 00000000..f0dd0866 --- /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 00000000..6e171099 --- /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 00000000..8d385076 --- /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 00000000..5376cac2 --- /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 00000000..f9a8f4d6 --- /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 00000000..2643625f --- /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 00000000..450742f8 --- /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 00000000..bc43a68d --- /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 00000000..74d4f237 --- /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 00000000..585157b6 --- /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 00000000..48e37596 --- /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 00000000..806aaa5c --- /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 00000000..6e171099 --- /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 00000000..191d2eab --- /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 00000000..0f9df7f5 --- /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 00000000..f9a8f4d6 --- /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 00000000..e6ed3752 --- /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 00000000..3ba9da89 --- /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 00000000..c9a132a9 --- /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 00000000..ace65cac --- /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 00000000..5d549bd5 --- /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 00000000..48e37596 --- /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 00000000..2d5a75ec --- /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 00000000..6e171099 --- /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 00000000..bd418ab7 --- /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 00000000..651a92cd --- /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 00000000..f9a8f4d6 --- /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 00000000..ecfaae87 --- /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 00000000..1d2d0aaf --- /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 00000000..1fa205e2 --- /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 00000000..0b1bf4f1 --- /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 00000000..70f5f379 --- /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 00000000..48e37596 --- /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 00000000..a0d270c6 --- /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 00000000..6e171099 --- /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 00000000..d2659477 --- /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 00000000..4568c82b --- /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 00000000..f9a8f4d6 --- /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 00000000..1cdcd960 --- /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 00000000..1c9cabde --- /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 00000000..5d2ae20c --- /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 00000000..dd4e4b7f --- /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 00000000..df3a335b --- /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 00000000..b62a989b --- /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 00000000..6d01df73 --- /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 00000000..6e171099 --- /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 00000000..afc8858f --- /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 00000000..218505f4 --- /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 00000000..c6b6d06c --- /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 00000000..eca6e68c --- /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 00000000..3394c0d3 --- /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 00000000..812a2cda --- /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 00000000..1b396dce --- /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 00000000..6dd14e9d --- /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 00000000..48e37596 --- /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 00000000..db24ae39 --- /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 00000000..6e171099 --- /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 00000000..9a0b1be4 --- /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 00000000..05aaa927 --- /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 00000000..f9a8f4d6 --- /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 00000000..5ceb5c16 --- /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 00000000..139e47a8 --- /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 00000000..8e7a1798 --- /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 00000000..6a4bbcc3 --- /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 00000000..df91b0e6 --- /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 00000000..bf7c86f0 --- /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 00000000..25545d75 --- /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 00000000..6dd14e9d --- /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 00000000..48e37596 --- /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 00000000..fe229103 --- /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 00000000..086f4317 --- /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 00000000..6e171099 --- /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 00000000..9a0b1be4 --- /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 00000000..afe999a4 --- /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 00000000..05aaa927 --- /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 00000000..f9a8f4d6 --- /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 00000000..31b6d1ed --- /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 00000000..139e47a8 --- /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 00000000..8e7a1798 --- /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 00000000..f47fa2ad --- /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 00000000..6d99e0d8 --- /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 00000000..50505b77 --- /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 00000000..48e37596 --- /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 00000000..93af1915 --- /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 00000000..6e171099 --- /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 00000000..5543f6b4 --- /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 00000000..138bb564 --- /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 00000000..f9a8f4d6 --- /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 00000000..c4d7a93b --- /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 00000000..989a0b9a --- /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 00000000..8e7a1798 --- /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 00000000..fe7c8087 --- /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 00000000..14f4d378 --- /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 00000000..14579e3d --- /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 00000000..48e37596 --- /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 00000000..51b27e49 --- /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 00000000..6e171099 --- /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 00000000..7b1b1a59 --- /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 00000000..a00c18af --- /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 00000000..f9a8f4d6 --- /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 00000000..54c2d4ed --- /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 00000000..52708d8f --- /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 00000000..8157fed7 --- /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 00000000..19945235 --- /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 00000000..be00015a --- /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 00000000..48e37596 --- /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 00000000..25eb7d22 --- /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 00000000..6e171099 --- /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 00000000..7dad320c --- /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 00000000..2da5133f --- /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 00000000..f9a8f4d6 --- /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 00000000..a88b00e7 --- /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 00000000..a7e452cc --- /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 00000000..bc4af1f6 --- /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 00000000..fc1482df --- /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 00000000..0ffed246 --- /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 00000000..48e37596 --- /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 00000000..11419b63 --- /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 00000000..6e171099 --- /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 00000000..9405de03 --- /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 00000000..fb36eda5 --- /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 00000000..f9a8f4d6 --- /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 00000000..e6c0e3c7 --- /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 00000000..4791fcf1 --- /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 00000000..4550a4c1 --- /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 00000000..8bf51cd1 --- /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 00000000..974a9303 --- /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 00000000..48e37596 --- /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 00000000..50b92181 --- /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 00000000..6e171099 --- /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 00000000..b17ac1e8 --- /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 00000000..0abc6b70 --- /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 00000000..f9a8f4d6 --- /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 00000000..0696bad6 --- /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 00000000..e10cb6cb --- /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 00000000..9d9b7857 --- /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 00000000..9881fbcc --- /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 00000000..626e1397 --- /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 00000000..48e37596 --- /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 00000000..72fbe354 --- /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 00000000..6e171099 --- /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 00000000..1e6d46a5 --- /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 00000000..74a98ba0 --- /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 00000000..f9a8f4d6 --- /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 00000000..4f531cfa --- /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 00000000..091595be --- /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 00000000..b72b4252 --- /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 00000000..8c8239b2 --- /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 00000000..9e28c342 --- /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 00000000..48e37596 --- /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 00000000..0a5dc6c1 --- /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 00000000..6e171099 --- /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 00000000..bb1486da --- /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 00000000..acf9c7af --- /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 00000000..f9a8f4d6 --- /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 00000000..ea82ad2d --- /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 00000000..4553a73b --- /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 00000000..72e601ca --- /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 00000000..cf4a9197 --- /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 00000000..e8279a7c --- /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 00000000..c026e046 --- /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 00000000..48e37596 --- /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 00000000..f2aa4740 --- /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 00000000..6e171099 --- /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 00000000..ce9ffe50 --- /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 00000000..74444e7b --- /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 00000000..f9a8f4d6 --- /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 00000000..0d8134f4 --- /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 00000000..fd2091ef --- /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 00000000..fe528229 --- /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 00000000..1615393f --- /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 00000000..7b13d173 --- /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 00000000..48e37596 --- /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 00000000..b8301c15 --- /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 00000000..6e171099 --- /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 00000000..34e9c752 --- /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 00000000..a3bfccdc --- /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 00000000..f9a8f4d6 --- /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 00000000..79e74689 --- /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 00000000..dc518069 --- /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 00000000..c2699768 --- /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 00000000..0b82a66a --- /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 00000000..f3f1b368 --- /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 00000000..48e37596 --- /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 00000000..c0164232 --- /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 00000000..6e171099 --- /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 00000000..fa15f445 --- /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 00000000..171a202e --- /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 00000000..f9a8f4d6 --- /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 00000000..fabeeda8 --- /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 00000000..80772886 --- /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 00000000..be4e3d30 --- /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 00000000..78549a68 --- /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 00000000..1c6244e4 --- /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 00000000..48e37596 --- /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 00000000..cb8d2713 --- /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 00000000..6e171099 --- /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 00000000..c4c95814 --- /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 00000000..fe39a711 --- /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 00000000..f9a8f4d6 --- /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 00000000..dcc8e776 --- /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 00000000..a3b3de61 --- /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 00000000..fe4de01e --- /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 00000000..ed911bc2 --- /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 00000000..a1aaecfc --- /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 00000000..48e37596 --- /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 00000000..25e4f1c5 --- /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 00000000..c4df546a --- /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 00000000..ff6c2125 --- /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 00000000..f9a8f4d6 --- /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 00000000..55d483f8 --- /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 00000000..2071da48 --- /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 00000000..a9e9a6df --- /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 00000000..6596fa7b --- /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 00000000..aa72abbf --- /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 00000000..48e37596 --- /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 00000000..b9390539 --- /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 00000000..48667b68 --- /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 00000000..c6b21792 --- /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 00000000..f9a8f4d6 --- /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 00000000..b9f3bd7c --- /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 00000000..eeaceb45 --- /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 00000000..24f29601 --- /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 00000000..abc47ad0 --- /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 00000000..743873af --- /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 00000000..48e37596 --- /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 00000000..3722b78d --- /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 00000000..6e171099 --- /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 00000000..cf2cb044 --- /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 00000000..b65370b9 --- /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 00000000..f9a8f4d6 --- /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 00000000..11dfd686 --- /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 00000000..7d08f40e --- /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 00000000..02cc14d5 --- /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 00000000..377b3670 --- /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 00000000..b7c1e0ff --- /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 00000000..48e37596 --- /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 00000000..acb51637 --- /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 00000000..6e171099 --- /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 00000000..0f55ece4 --- /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 00000000..2bbc94e7 --- /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 00000000..f9a8f4d6 --- /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 00000000..4418925d --- /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 00000000..fb0a23be --- /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 00000000..070ef059 --- /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 00000000..41bb78c9 --- /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 00000000..394de465 --- /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 00000000..48e37596 --- /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 00000000..9acbccb0 --- /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 00000000..6e171099 --- /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 00000000..32179338 --- /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 00000000..d4e41671 --- /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 00000000..f9a8f4d6 --- /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 00000000..abb893d6 --- /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 00000000..b098bffa --- /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 00000000..826f9df9 --- /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 00000000..706e50e9 --- /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 00000000..1d5acbd2 --- /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 00000000..48e37596 --- /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 00000000..4b93e09e --- /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 00000000..6e171099 --- /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 00000000..15ffa4c4 --- /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 00000000..81d2e39b --- /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 00000000..f9a8f4d6 --- /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 00000000..eaf810ce --- /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 00000000..41afa583 --- /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 00000000..b087e062 --- /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 00000000..3cfac6ba --- /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 00000000..d25b57a6 --- /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 00000000..48e37596 --- /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 00000000..74131d3e --- /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 00000000..6e171099 --- /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 00000000..75cfa894 --- /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 00000000..d857183c --- /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 00000000..f9a8f4d6 --- /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 00000000..7c78e51c --- /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 00000000..dea9c4f1 --- /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 00000000..1223c7dc --- /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 00000000..138dee2d --- /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 00000000..fc31c294 --- /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 00000000..48e37596 --- /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 00000000..1467fd64 --- /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 00000000..6e171099 --- /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 00000000..d0d9f870 --- /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 00000000..5adb74b7 --- /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 00000000..f9a8f4d6 --- /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 00000000..feb816c8 --- /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 00000000..77d8142b --- /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 00000000..fa4b3372 --- /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 00000000..fb13fcb1 --- /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 00000000..cb664a47 --- /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 00000000..dfe3b5a9 --- /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 00000000..48e37596 --- /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 00000000..c3051c52 --- /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 00000000..6e171099 --- /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 00000000..3c31c198 --- /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 00000000..174be620 --- /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 00000000..f9a8f4d6 --- /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 00000000..5ff9537f --- /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 00000000..40cc9112 --- /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 00000000..058b7129 --- /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 00000000..30665c97 --- /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 00000000..4018b5f9 --- /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 00000000..48e37596 --- /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 00000000..d6606bb7 --- /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 00000000..6e171099 --- /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 00000000..625d999e --- /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 00000000..b150c1a2 --- /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 00000000..f9a8f4d6 --- /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 00000000..9f1415d8 --- /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 00000000..0a6f0ffd --- /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 00000000..30fd1fd3 --- /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 00000000..d986cc7a --- /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 00000000..4ae3c22d --- /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 00000000..a93ed8a7 --- /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 00000000..48e37596 --- /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 00000000..7a1f80d6 --- /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 00000000..6e171099 --- /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 00000000..27f6c159 --- /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 00000000..e8aecea5 --- /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 00000000..f9a8f4d6 --- /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 00000000..9ef9163c --- /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 00000000..57bac483 --- /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 00000000..28dd7326 --- /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 00000000..5178935c --- /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 00000000..50505b77 --- /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 00000000..48e37596 --- /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 00000000..2dcc3531 --- /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 00000000..6e171099 --- /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 00000000..5543f6b4 --- /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 00000000..138bb564 --- /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 00000000..f9a8f4d6 --- /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 00000000..bd56e8a3 --- /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 00000000..989a0b9a --- /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 00000000..9a5b11b1 --- /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 00000000..ea2df27c --- /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 00000000..051c877d --- /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 00000000..48e37596 --- /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 00000000..dc1a0b6f --- /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 00000000..6e171099 --- /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 00000000..9a0b1be4 --- /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 00000000..05aaa927 --- /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 00000000..f9a8f4d6 --- /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 00000000..98799b75 --- /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 00000000..139e47a8 --- /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 00000000..711e0959 --- /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 00000000..543c93d2 --- /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