diff --git a/.github/agents/docs-maintenance.agent.md b/.github/agents/docs-maintenance.agent.md index 9b605c265..9b97fecf4 100644 --- a/.github/agents/docs-maintenance.agent.md +++ b/.github/agents/docs-maintenance.agent.md @@ -344,7 +344,7 @@ cat nodejs/src/types.ts | grep -A 10 "export interface ExportSessionOptions" **Must match:** - `CopilotClient` constructor options: `cliPath`, `cliUrl`, `useStdio`, `port`, `logLevel`, `autoStart`, `autoRestart`, `env`, `githubToken`, `useLoggedInUser` - `createSession()` config: `model`, `tools`, `hooks`, `systemMessage`, `mcpServers`, `availableTools`, `excludedTools`, `streaming`, `reasoningEffort`, `provider`, `infiniteSessions`, `customAgents`, `workingDirectory` -- `CopilotSession` methods: `send()`, `sendAndWait()`, `getMessages()`, `destroy()`, `abort()`, `on()`, `once()`, `off()` +- `CopilotSession` methods: `send()`, `sendAndWait()`, `getMessages()`, `disconnect()`, `abort()`, `on()`, `once()`, `off()` - Hook names: `onPreToolUse`, `onPostToolUse`, `onUserPromptSubmitted`, `onSessionStart`, `onSessionEnd`, `onErrorOccurred` #### Python Validation @@ -362,7 +362,7 @@ cat python/copilot/types.py | grep -A 15 "class SessionHooks" **Must match (snake_case):** - `CopilotClient` options: `cli_path`, `cli_url`, `use_stdio`, `port`, `log_level`, `auto_start`, `auto_restart`, `env`, `github_token`, `use_logged_in_user` - `create_session()` config keys: `model`, `tools`, `hooks`, `system_message`, `mcp_servers`, `available_tools`, `excluded_tools`, `streaming`, `reasoning_effort`, `provider`, `infinite_sessions`, `custom_agents`, `working_directory` -- `CopilotSession` methods: `send()`, `send_and_wait()`, `get_messages()`, `destroy()`, `abort()`, `export_session()` +- `CopilotSession` methods: `send()`, `send_and_wait()`, `get_messages()`, `disconnect()`, `abort()`, `export_session()` - Hook names: `on_pre_tool_use`, `on_post_tool_use`, `on_user_prompt_submitted`, `on_session_start`, `on_session_end`, `on_error_occurred` #### Go Validation @@ -380,7 +380,7 @@ cat go/types.go | grep -A 15 "type SessionHooks struct" **Must match (PascalCase for exported):** - `ClientOptions` fields: `CLIPath`, `CLIUrl`, `UseStdio`, `Port`, `LogLevel`, `AutoStart`, `AutoRestart`, `Env`, `GithubToken`, `UseLoggedInUser` - `SessionConfig` fields: `Model`, `Tools`, `Hooks`, `SystemMessage`, `MCPServers`, `AvailableTools`, `ExcludedTools`, `Streaming`, `ReasoningEffort`, `Provider`, `InfiniteSessions`, `CustomAgents`, `WorkingDirectory` -- `Session` methods: `Send()`, `SendAndWait()`, `GetMessages()`, `Destroy()`, `Abort()`, `ExportSession()` +- `Session` methods: `Send()`, `SendAndWait()`, `GetMessages()`, `Disconnect()`, `Abort()`, `ExportSession()` - Hook fields: `OnPreToolUse`, `OnPostToolUse`, `OnUserPromptSubmitted`, `OnSessionStart`, `OnSessionEnd`, `OnErrorOccurred` #### .NET Validation diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json new file mode 100644 index 000000000..9e8207fe4 --- /dev/null +++ b/.github/aw/actions-lock.json @@ -0,0 +1,34 @@ +{ + "entries": { + "actions/checkout@v6.0.2": { + "repo": "actions/checkout", + "version": "v6.0.2", + "sha": "de0fac2e4500dabe0009e67214ff5f5447ce83dd" + }, + "actions/download-artifact@v8.0.0": { + "repo": "actions/download-artifact", + "version": "v8.0.0", + "sha": "70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3" + }, + "actions/github-script@v8": { + "repo": "actions/github-script", + "version": "v8", + "sha": "ed597411d8f924073f98dfc5c65a23a2325f34cd" + }, + "actions/upload-artifact@v7.0.0": { + "repo": "actions/upload-artifact", + "version": "v7.0.0", + "sha": "bbbca2ddaa5d8feaa63e36b76fdaad77386f024f" + }, + "github/gh-aw/actions/setup@v0.50.5": { + "repo": "github/gh-aw/actions/setup", + "version": "v0.50.5", + "sha": "a7d371cc7e68f270ded0592942424548e05bf1c2" + }, + "github/gh-aw/actions/setup@v0.52.1": { + "repo": "github/gh-aw/actions/setup", + "version": "v0.52.1", + "sha": "a86e657586e4ac5f549a790628971ec02f6a4a8f" + } + } +} diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 63f2eeb23..804e6f0d4 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,34 +1,38 @@ version: 2 +multi-ecosystem-groups: + all: + schedule: + interval: 'weekly' updates: - package-ecosystem: 'github-actions' directory: '/' - schedule: - interval: 'weekly' + multi-ecosystem-group: 'all' + patterns: ['*'] - package-ecosystem: 'devcontainers' directory: '/' - schedule: - interval: 'weekly' + multi-ecosystem-group: 'all' + patterns: ['*'] # Node.js dependencies - package-ecosystem: 'npm' directory: '/nodejs' - schedule: - interval: 'weekly' + multi-ecosystem-group: 'all' + patterns: ['*'] - package-ecosystem: 'npm' directory: '/test/harness' - schedule: - interval: 'weekly' + multi-ecosystem-group: 'all' + patterns: ['*'] # Python dependencies - package-ecosystem: 'pip' directory: '/python' - schedule: - interval: 'weekly' + multi-ecosystem-group: 'all' + patterns: ['*'] # Go dependencies - package-ecosystem: 'gomod' directory: '/go' - schedule: - interval: 'weekly' + multi-ecosystem-group: 'all' + patterns: ['*'] # .NET dependencies - package-ecosystem: 'nuget' directory: '/dotnet' - schedule: - interval: 'weekly' + multi-ecosystem-group: 'all' + patterns: ['*'] diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 0e3f52360..afe9b03bd 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -58,7 +58,7 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" # Install just command runner - name: Install just @@ -77,7 +77,7 @@ jobs: # Install Python dependencies - name: Install Python dependencies working-directory: ./python - run: uv sync --locked --all-extras --dev + run: uv sync --all-extras --dev # Install Go dependencies - name: Install Go dependencies diff --git a/.github/workflows/cross-repo-issue-analysis.lock.yml b/.github/workflows/cross-repo-issue-analysis.lock.yml new file mode 100644 index 000000000..c7cd9f4de --- /dev/null +++ b/.github/workflows/cross-repo-issue-analysis.lock.yml @@ -0,0 +1,1338 @@ +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.50.5). DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Analyzes copilot-sdk issues to determine if a fix is needed in copilot-agent-runtime, then opens a linked issue and suggested-fix PR there +# +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"553bdce55a05e3f846f312d711680323ba79effef8a001bd23cb72c1c0459413","compiler_version":"v0.50.5"} + +name: "SDK Runtime Triage" +"on": + issues: + types: + - labeled + workflow_dispatch: + inputs: + issue_number: + description: Issue number to analyze + required: true + type: string + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number }}" + +run-name: "SDK Runtime Triage" + +jobs: + activation: + needs: pre_activation + if: > + (needs.pre_activation.outputs.activated == 'true') && (github.event_name == 'workflow_dispatch' || github.event.label.name == 'runtime triage') + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + body: ${{ steps.sanitized.outputs.body }} + comment_id: "" + comment_repo: "" + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + text: ${{ steps.sanitized.outputs.text }} + title: ${{ steps.sanitized.outputs.title }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@a7d371cc7e68f270ded0592942424548e05bf1c2 # v0.50.5 + with: + destination: /opt/gh-aw/actions + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Validate context variables + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/validate_context_variables.cjs'); + await main(); + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + .github + .agents + fetch-depth: 1 + persist-credentials: false + - name: Check workflow file timestamps + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_FILE: "cross-repo-issue-analysis.lock.yml" + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Compute current body text + id: sanitized + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/compute_text.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_EXPR_54492A5B: ${{ github.event.issue.number || inputs.issue_number }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_ISSUE_TITLE: ${{ github.event.issue.title }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + { + cat << 'GH_AW_PROMPT_EOF' + + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/xpia.md" + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" + cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_EOF' + + Tools: create_issue, create_pull_request, add_labels, missing_tool, missing_data + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/safe_outputs_create_pull_request.md" + cat << 'GH_AW_PROMPT_EOF' + + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/cross-repo-issue-analysis.md}} + GH_AW_PROMPT_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_EXPR_54492A5B: ${{ github.event.issue.number || inputs.issue_number }} + GH_AW_GITHUB_EVENT_ISSUE_TITLE: ${{ github.event.issue.title }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_EXPR_54492A5B: ${{ github.event.issue.number || inputs.issue_number }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_ISSUE_TITLE: ${{ github.event.issue.title }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_EXPR_54492A5B: process.env.GH_AW_EXPR_54492A5B, + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_TITLE: process.env.GH_AW_GITHUB_EVENT_ISSUE_TITLE, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Upload prompt artifact + if: success() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: prompt + path: /tmp/gh-aw/aw-prompts/prompt.txt + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_WORKFLOW_ID_SANITIZED: crossrepoissueanalysis + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + model: ${{ steps.generate_aw_info.outputs.model }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@a7d371cc7e68f270ded0592942424548e05bf1c2 # v0.50.5 + with: + destination: /opt/gh-aw/actions + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + - name: Clone copilot-agent-runtime + run: git clone --depth 1 https://x-access-token:${{ secrets.RUNTIME_TRIAGE_TOKEN }}@github.com/github/copilot-agent-runtime.git ${{ github.workspace }}/copilot-agent-runtime + + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + with: + github-token: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Generate agentic run info + id: generate_aw_info + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "copilot", + engine_name: "GitHub Copilot CLI", + model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", + version: "", + agent_version: "0.0.418", + cli_version: "v0.50.5", + workflow_name: "SDK Runtime Triage", + experimental: false, + supports_tools_allowlist: true, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + staged: false, + allowed_domains: ["defaults"], + firewall_enabled: true, + awf_version: "v0.23.0", + awmg_version: "v0.1.5", + steps: { + firewall: "squid" + }, + created_at: new Date().toISOString() + }; + + // Write to /tmp/gh-aw directory to avoid inclusion in PR + const tmpPath = '/tmp/gh-aw/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + + // Set model as output for reuse in other steps/jobs + core.setOutput('model', awInfo.model); + - name: Install GitHub Copilot CLI + run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.418 + - name: Install awf binary + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.23.0 + - name: Determine automatic lockdown mode for GitHub MCP Server + id: determine-automatic-lockdown + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + CUSTOM_GITHUB_TOKEN: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + with: + script: | + const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); + - name: Download container images + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.23.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.23.0 ghcr.io/github/gh-aw-firewall/squid:0.23.0 ghcr.io/github/gh-aw-mcpg:v0.1.5 ghcr.io/github/github-mcp-server:v0.31.0 node:lts-alpine + - name: Write Safe Outputs Config + run: | + mkdir -p /opt/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' + {"add_labels":{"allowed":["runtime","sdk-fix-only","needs-investigation"],"max":3,"target":"triggering"},"create_issue":{"max":1},"create_pull_request":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}} + GH_AW_SAFE_OUTPUTS_CONFIG_EOF + cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' + [ + { + "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[copilot-sdk] \". Labels [upstream-from-sdk ai-triaged] will be automatically added. Issues will be created in repository \"github/copilot-agent-runtime\".", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.", + "type": "string" + }, + "labels": { + "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.", + "items": { + "type": "string" + }, + "type": "array" + }, + "parent": { + "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123', 'aw_Test123') from a previously created issue in the same workflow run.", + "type": [ + "number", + "string" + ] + }, + "temporary_id": { + "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 3 to 8 alphanumeric characters (e.g., 'aw_abc1', 'aw_Test123'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", + "pattern": "^aw_[A-Za-z0-9]{3,8}$", + "type": "string" + }, + "title": { + "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.", + "type": "string" + } + }, + "required": [ + "title", + "body" + ], + "type": "object" + }, + "name": "create_issue" + }, + { + "description": "Create a new GitHub pull request to propose code changes. Use this after making file edits to submit them for review and merging. The PR will be created from the current branch with your committed changes. For code review comments on an existing PR, use create_pull_request_review_comment instead. CONSTRAINTS: Maximum 1 pull request(s) can be created. Title will be prefixed with \"[copilot-sdk] \". Labels [upstream-from-sdk ai-suggested-fix] will be automatically added. PRs will be created as drafts.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Detailed PR description in Markdown. Include what changes were made, why, testing notes, and any breaking changes. Do NOT repeat the title as a heading.", + "type": "string" + }, + "branch": { + "description": "Source branch name containing the changes. If omitted, uses the current working branch.", + "type": "string" + }, + "draft": { + "description": "Whether to create the PR as a draft. Draft PRs cannot be merged until marked as ready for review. Use mark_pull_request_as_ready_for_review to convert a draft PR. Default: true.", + "type": "boolean" + }, + "labels": { + "description": "Labels to categorize the PR (e.g., 'enhancement', 'bugfix'). Labels must exist in the repository.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "Concise PR title describing the changes. Follow repository conventions (e.g., conventional commits). The title appears as the main heading.", + "type": "string" + } + }, + "required": [ + "title", + "body" + ], + "type": "object" + }, + "name": "create_pull_request" + }, + { + "description": "Add labels to an existing GitHub issue or pull request for categorization and filtering. Labels must already exist in the repository. For creating new issues with labels, use create_issue with the labels property instead. CONSTRAINTS: Maximum 3 label(s) can be added. Only these labels are allowed: [runtime sdk-fix-only needs-investigation]. Target: triggering.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "item_number": { + "description": "Issue or PR number to add labels to. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). If omitted, adds labels to the issue or PR that triggered this workflow. Only works for issue or pull_request event triggers. For schedule, workflow_dispatch, or other triggers, item_number is required — omitting it will silently skip the label operation.", + "type": "number" + }, + "labels": { + "description": "Label names to add (e.g., ['bug', 'priority-high']). Labels must exist in the repository.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "name": "add_labels" + }, + { + "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "reason": { + "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).", + "type": "string" + }, + "tool": { + "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", + "type": "string" + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "name": "missing_tool" + }, + { + "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "name": "noop" + }, + { + "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "context": { + "description": "Additional context about the missing data or where it should come from (max 256 characters).", + "type": "string" + }, + "data_type": { + "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.", + "type": "string" + }, + "reason": { + "description": "Explanation of why this data is needed to complete the task (max 256 characters).", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "missing_data" + } + ] + GH_AW_SAFE_OUTPUTS_TOOLS_EOF + cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' + { + "add_labels": { + "defaultMax": 5, + "fields": { + "item_number": { + "issueOrPRNumber": true + }, + "labels": { + "required": true, + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "repo": { + "type": "string", + "maxLength": 256 + } + } + }, + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "draft": { + "type": "boolean" + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + GH_AW_SAFE_OUTPUTS_VALIDATION_EOF + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash /opt/gh-aw/actions/start_safe_outputs_server.sh + + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.5' + + mkdir -p /home/runner/.copilot + cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v0.31.0", + "env": { + "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", + "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + } + }, + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_EOF + - name: Generate workflow overview + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); + await generateWorkflowOverview(core); + - name: Download prompt artifact + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: prompt + path: /tmp/gh-aw/aw-prompts + - name: Clean git credentials + run: bash /opt/gh-aw/actions/clean_git_credentials.sh + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + # --allow-tool github + # --allow-tool safeoutputs + # --allow-tool shell(cat) + # --allow-tool shell(cat:*) + # --allow-tool shell(date) + # --allow-tool shell(echo) + # --allow-tool shell(find:*) + # --allow-tool shell(git add:*) + # --allow-tool shell(git branch:*) + # --allow-tool shell(git checkout:*) + # --allow-tool shell(git commit:*) + # --allow-tool shell(git merge:*) + # --allow-tool shell(git rm:*) + # --allow-tool shell(git status) + # --allow-tool shell(git switch:*) + # --allow-tool shell(grep) + # --allow-tool shell(grep:*) + # --allow-tool shell(head) + # --allow-tool shell(head:*) + # --allow-tool shell(ls) + # --allow-tool shell(ls:*) + # --allow-tool shell(pwd) + # --allow-tool shell(sort) + # --allow-tool shell(tail) + # --allow-tool shell(tail:*) + # --allow-tool shell(uniq) + # --allow-tool shell(wc) + # --allow-tool shell(wc:*) + # --allow-tool shell(yq) + # --allow-tool write + timeout-minutes: 20 + run: | + set -o pipefail + # shellcheck disable=SC1003 + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(cat:*)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(find:*)'\'' --allow-tool '\''shell(git add:*)'\'' --allow-tool '\''shell(git branch:*)'\'' --allow-tool '\''shell(git checkout:*)'\'' --allow-tool '\''shell(git commit:*)'\'' --allow-tool '\''shell(git merge:*)'\'' --allow-tool '\''shell(git rm:*)'\'' --allow-tool '\''shell(git status)'\'' --allow-tool '\''shell(git switch:*)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(grep:*)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(head:*)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(ls:*)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(tail:*)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(wc:*)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_MODEL_AGENT_COPILOT: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + GITHUB_WORKSPACE: ${{ github.workspace }} + XDG_CONFIG_HOME: /home/runner + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: | + # Copy Copilot session state files to logs folder for artifact collection + # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them + SESSION_STATE_DIR="$HOME/.copilot/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + + if [ -d "$SESSION_STATE_DIR" ]; then + echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" + mkdir -p "$LOGS_DIR" + cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true + echo "Session state files copied successfully" + else + echo "No session-state directory found at $SESSION_STATE_DIR" + fi + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,RUNTIME_TRIAGE_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_RUNTIME_TRIAGE_TOKEN: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + - name: Upload Safe Outputs + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: safe-output + path: ${{ env.GH_AW_SAFE_OUTPUTS }} + if-no-files-found: warn + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" + GH_AW_ALLOWED_GITHUB_REFS: "repo,github/copilot-agent-runtime" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Upload sanitized agent output + if: always() && env.GH_AW_AGENT_OUTPUT + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: agent-output + path: ${{ env.GH_AW_AGENT_OUTPUT }} + if-no-files-found: warn + - name: Upload engine output files + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: agent_outputs + path: | + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + if-no-files-found: ignore + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: agent-artifacts + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + /tmp/gh-aw/aw-*.patch + if-no-files-found: ignore + # --- Threat Detection (inline) --- + - name: Check if detection needed + id: detection_guard + if: always() + env: + OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} + HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + run: | + if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then + echo "run_detection=true" >> "$GITHUB_OUTPUT" + echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" + else + echo "run_detection=false" >> "$GITHUB_OUTPUT" + echo "Detection skipped: no agent outputs or patches to analyze" + fi + - name: Clear MCP configuration for detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + rm -f /tmp/gh-aw/mcp-config/mcp-servers.json + rm -f /home/runner/.copilot/mcp-config.json + rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" + - name: Prepare threat detection files + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection/aw-prompts + cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true + cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true + for f in /tmp/gh-aw/aw-*.patch; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + echo "Prepared threat detection files:" + ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + - name: Setup threat detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + WORKFLOW_NAME: "SDK Runtime Triage" + WORKFLOW_DESCRIPTION: "Analyzes copilot-sdk issues to determine if a fix is needed in copilot-agent-runtime, then opens a linked issue and suggested-fix PR there" + HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Execute GitHub Copilot CLI + if: always() && steps.detection_guard.outputs.run_detection == 'true' + id: detection_agentic_execution + # Copilot CLI tool arguments (sorted): + # --allow-tool shell(cat) + # --allow-tool shell(grep) + # --allow-tool shell(head) + # --allow-tool shell(jq) + # --allow-tool shell(ls) + # --allow-tool shell(tail) + # --allow-tool shell(wc) + timeout-minutes: 20 + run: | + set -o pipefail + # shellcheck disable=SC1003 + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_DETECTION_COPILOT:+ --model "$GH_AW_MODEL_DETECTION_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_MODEL_DETECTION_COPILOT: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + GITHUB_WORKSPACE: ${{ github.workspace }} + XDG_CONFIG_HOME: /home/runner + - name: Parse threat detection results + id: parse_detection_results + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + - name: Upload threat detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: threat-detection.log + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + - name: Set detection conclusion + id: detection_conclusion + if: always() + env: + RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} + DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} + run: | + if [[ "$RUN_DETECTION" != "true" ]]; then + echo "conclusion=skipped" >> "$GITHUB_OUTPUT" + echo "success=true" >> "$GITHUB_OUTPUT" + echo "Detection was not needed, marking as skipped" + elif [[ "$DETECTION_SUCCESS" == "true" ]]; then + echo "conclusion=success" >> "$GITHUB_OUTPUT" + echo "success=true" >> "$GITHUB_OUTPUT" + echo "Detection passed successfully" + else + echo "conclusion=failure" >> "$GITHUB_OUTPUT" + echo "success=false" >> "$GITHUB_OUTPUT" + echo "Detection found issues" + fi + + conclusion: + needs: + - activation + - agent + - safe_outputs + if: (always()) && (needs.agent.result != 'skipped') + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + outputs: + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@a7d371cc7e68f270ded0592942424548e05bf1c2 # v0.50.5 + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process No-Op Messages + id: noop + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "SDK Runtime Triage" + with: + github-token: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/noop.cjs'); + await main(); + - name: Record Missing Tool + id: missing_tool + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "SDK Runtime Triage" + with: + github-token: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Handle Agent Failure + id: handle_agent_failure + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "SDK Runtime Triage" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "cross-repo-issue-analysis" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_CODE_PUSH_FAILURE_ERRORS: ${{ needs.safe_outputs.outputs.code_push_failure_errors }} + GH_AW_CODE_PUSH_FAILURE_COUNT: ${{ needs.safe_outputs.outputs.code_push_failure_count }} + GH_AW_GROUP_REPORTS: "false" + with: + github-token: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + - name: Handle No-Op Message + id: handle_noop_message + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "SDK Runtime Triage" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Handle Create Pull Request Error + id: handle_create_pr_error + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "SDK Runtime Triage" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + github-token: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_create_pr_error.cjs'); + await main(); + + pre_activation: + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'runtime triage' + runs-on: ubuntu-slim + outputs: + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + matched_command: '' + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@a7d371cc7e68f270ded0592942424548e05bf1c2 # v0.50.5 + with: + destination: /opt/gh-aw/actions + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_REQUIRED_ROLES: admin,maintainer,write + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); + await main(); + + safe_outputs: + needs: + - activation + - agent + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + timeout-minutes: 15 + env: + GH_AW_ENGINE_ID: "copilot" + GH_AW_WORKFLOW_ID: "cross-repo-issue-analysis" + GH_AW_WORKFLOW_NAME: "SDK Runtime Triage" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@a7d371cc7e68f270ded0592942424548e05bf1c2 # v0.50.5 + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Download patch artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent-artifacts + path: /tmp/gh-aw/ + - name: Checkout repository + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: github/copilot-agent-runtime + ref: ${{ github.base_ref || github.ref_name }} + token: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + persist-credentials: false + fetch-depth: 1 + - name: Configure Git credentials + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + env: + REPO_NAME: "github/copilot-agent-runtime" + SERVER_URL: ${{ github.server_url }} + GIT_TOKEN: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_labels\":{\"allowed\":[\"runtime\",\"sdk-fix-only\",\"needs-investigation\"],\"max\":3,\"target\":\"triggering\"},\"create_issue\":{\"labels\":[\"upstream-from-sdk\",\"ai-triaged\"],\"max\":1,\"target-repo\":\"github/copilot-agent-runtime\",\"title_prefix\":\"[copilot-sdk] \"},\"create_pull_request\":{\"base_branch\":\"${{ github.base_ref || github.ref_name }}\",\"draft\":true,\"labels\":[\"upstream-from-sdk\",\"ai-suggested-fix\"],\"max\":1,\"max_patch_size\":1024,\"target-repo\":\"github/copilot-agent-runtime\",\"title_prefix\":\"[copilot-sdk] \"},\"missing_data\":{},\"missing_tool\":{}}" + GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} + with: + github-token: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + - name: Upload safe output items manifest + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: safe-output-items + path: /tmp/safe-output-items.jsonl + if-no-files-found: warn + diff --git a/.github/workflows/cross-repo-issue-analysis.md b/.github/workflows/cross-repo-issue-analysis.md new file mode 100644 index 000000000..8a0218427 --- /dev/null +++ b/.github/workflows/cross-repo-issue-analysis.md @@ -0,0 +1,123 @@ +--- +description: Analyzes copilot-sdk issues to determine if a fix is needed in copilot-agent-runtime, then opens a linked issue and suggested-fix PR there +on: + issues: + types: [labeled] + workflow_dispatch: + inputs: + issue_number: + description: "Issue number to analyze" + required: true + type: string +if: "github.event_name == 'workflow_dispatch' || github.event.label.name == 'runtime triage'" +permissions: + contents: read + issues: read + pull-requests: read +steps: + - name: Clone copilot-agent-runtime + run: git clone --depth 1 https://x-access-token:${{ secrets.RUNTIME_TRIAGE_TOKEN }}@github.com/github/copilot-agent-runtime.git ${{ github.workspace }}/copilot-agent-runtime +tools: + github: + toolsets: [default] + github-token: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + edit: + bash: + - "grep:*" + - "find:*" + - "cat:*" + - "head:*" + - "tail:*" + - "wc:*" + - "ls:*" +safe-outputs: + github-token: ${{ secrets.RUNTIME_TRIAGE_TOKEN }} + allowed-github-references: ["repo", "github/copilot-agent-runtime"] + add-labels: + allowed: [runtime, sdk-fix-only, needs-investigation] + max: 3 + target: triggering + create-issue: + title-prefix: "[copilot-sdk] " + labels: [upstream-from-sdk, ai-triaged] + target-repo: "github/copilot-agent-runtime" + max: 1 + create-pull-request: + title-prefix: "[copilot-sdk] " + labels: [upstream-from-sdk, ai-suggested-fix] + draft: true + target-repo: "github/copilot-agent-runtime" + +timeout-minutes: 20 +--- + +# SDK Runtime Triage + +You are an expert agent that analyzes issues filed in the **copilot-sdk** repository to determine whether the root cause and fix live in this repo or in the **copilot-agent-runtime** repo (`github/copilot-agent-runtime`). + +## Context + +- Repository: ${{ github.repository }} +- Issue number: ${{ github.event.issue.number || inputs.issue_number }} +- Issue title: ${{ github.event.issue.title }} + +The **copilot-sdk** repo is a multi-language SDK (Node/TS, Python, Go, .NET) that communicates with the Copilot CLI via JSON-RPC. The **copilot-agent-runtime** repo contains the CLI/server that the SDK talks to. Many issues filed against the SDK are actually caused by behavior in the runtime. + +## Your Task + +### Step 1: Understand the Issue + +Use GitHub tools to fetch the full issue body, comments, and any linked references for issue `${{ github.event.issue.number || inputs.issue_number }}` in `${{ github.repository }}`. + +### Step 2: Analyze Against copilot-sdk + +Search the copilot-sdk codebase on disk to understand whether the reported problem could originate here. The repo is checked out at the default working directory. + +- Use bash tools (`grep`, `find`, `cat`) to search the relevant SDK language implementation (`nodejs/src/`, `python/copilot/`, `go/`, `dotnet/src/`) +- Look at the JSON-RPC client layer, session management, event handling, and tool definitions +- Check if the issue relates to SDK-side logic (type generation, streaming, event parsing, client options, etc.) + +### Step 3: Investigate copilot-agent-runtime + +If the issue does NOT appear to be caused by SDK code, or you suspect the runtime is involved, investigate the **copilot-agent-runtime** repo. It has been cloned to `./copilot-agent-runtime/` in the current working directory. + +- Use bash tools (`grep`, `find`, `cat`) to search the runtime codebase at `./copilot-agent-runtime/` +- Look at the server-side JSON-RPC handling, session management, tool execution, and response generation +- Focus on the areas that correspond to the reported issue (e.g., if the issue is about streaming, look at the runtime's streaming implementation) + +Common areas where runtime fixes are needed: +- JSON-RPC protocol handling and response formatting +- Session lifecycle (creation, persistence, compaction, destruction) +- Tool execution and permission handling +- Model/API interaction (prompt construction, response parsing) +- Streaming event generation (deltas, completions) +- Error handling and error response formatting + +### Step 4: Make Your Determination + +Classify the issue into one of these categories: + +1. **SDK-fix-only**: The bug/feature is entirely in the SDK code. Label the issue `sdk-fix-only`. + +2. **Runtime**: The root cause is in copilot-agent-runtime. Do ALL of the following: + - Label the original issue `runtime` + - Create an issue in `github/copilot-agent-runtime` that: + - Clearly describes the problem and root cause + - References the original SDK issue (e.g., `github/copilot-sdk#123`) + - Includes the specific files and code paths involved + - Suggests a fix approach + - Create a draft PR in `github/copilot-agent-runtime` with a suggested fix: + - Make the minimal, targeted code changes needed + - Include a clear PR description linking back to both issues + - If you're uncertain about the fix, still create the PR as a starting point for discussion + +3. **Needs-investigation**: You cannot confidently determine the root cause. Label the issue `needs-investigation`. + +## Guidelines + +1. **Be thorough but focused**: Read enough code to be confident in your analysis, but don't read every file in both repos +2. **Err on the side of creating the runtime issue**: If there's a reasonable chance the fix is in the runtime, create the issue. False positives are better than missed upstream bugs. +3. **Make actionable PRs**: Even if the fix isn't perfect, a draft PR with a concrete starting point is more useful than just an issue description +4. **Link everything**: Always cross-reference between the SDK issue, runtime issue, and runtime PR so maintainers can follow the trail +5. **Be specific**: When describing the root cause, point to specific files, functions, and line numbers in both repos +6. **Don't duplicate**: Before creating a runtime issue, search existing open issues in `github/copilot-agent-runtime` to avoid duplicates. If a related issue exists, reference it instead of creating a new one. diff --git a/.github/workflows/docs-validation.yml b/.github/workflows/docs-validation.yml index 20031af07..89d2fa2a9 100644 --- a/.github/workflows/docs-validation.yml +++ b/.github/workflows/docs-validation.yml @@ -108,7 +108,7 @@ jobs: - uses: actions/setup-dotnet@v5 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Install validation dependencies working-directory: scripts/docs-validation diff --git a/.github/workflows/dotnet-sdk-tests.yml b/.github/workflows/dotnet-sdk-tests.yml index bbe577bc1..3ca9d1de9 100644 --- a/.github/workflows/dotnet-sdk-tests.yml +++ b/.github/workflows/dotnet-sdk-tests.yml @@ -43,7 +43,7 @@ jobs: - uses: actions/checkout@v6.0.2 - uses: actions/setup-dotnet@v5 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - uses: actions/setup-node@v6 with: node-version: "22" diff --git a/.github/workflows/issue-triage.lock.yml b/.github/workflows/issue-triage.lock.yml index 22c6e02b5..73b5b71ec 100644 --- a/.github/workflows/issue-triage.lock.yml +++ b/.github/workflows/issue-triage.lock.yml @@ -50,7 +50,7 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@v0.40.0 + uses: githubnext/gh-aw/actions/setup@v0.53.0 with: destination: /opt/gh-aw/actions - name: Check workflow file timestamps @@ -88,7 +88,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@v0.40.0 + uses: githubnext/gh-aw/actions/setup@v0.53.0 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -857,7 +857,7 @@ jobs: SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload Safe Outputs if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: safe-output path: ${{ env.GH_AW_SAFE_OUTPUTS }} @@ -878,13 +878,13 @@ jobs: await main(); - name: Upload sanitized agent output if: always() && env.GH_AW_AGENT_OUTPUT - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: agent-output path: ${{ env.GH_AW_AGENT_OUTPUT }} if-no-files-found: warn - name: Upload engine output files - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: agent_outputs path: | @@ -924,7 +924,7 @@ jobs: - name: Upload agent artifacts if: always() continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: agent-artifacts path: | @@ -954,7 +954,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@v0.40.0 + uses: githubnext/gh-aw/actions/setup@v0.53.0 with: destination: /opt/gh-aw/actions - name: Debug job inputs @@ -970,7 +970,7 @@ jobs: echo "Agent Conclusion: $AGENT_CONCLUSION" - name: Download agent output artifact continue-on-error: true - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: agent-output path: /tmp/gh-aw/safeoutputs/ @@ -1051,18 +1051,18 @@ jobs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@v0.40.0 + uses: githubnext/gh-aw/actions/setup@v0.53.0 with: destination: /opt/gh-aw/actions - name: Download agent artifacts continue-on-error: true - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: agent-artifacts path: /tmp/gh-aw/threat-detection/ - name: Download agent output artifact continue-on-error: true - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: agent-output path: /tmp/gh-aw/threat-detection/ @@ -1176,7 +1176,7 @@ jobs: await main(); - name: Upload threat detection log if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: threat-detection.log path: /tmp/gh-aw/threat-detection/detection.log @@ -1203,12 +1203,12 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@v0.40.0 + uses: githubnext/gh-aw/actions/setup@v0.53.0 with: destination: /opt/gh-aw/actions - name: Download agent output artifact continue-on-error: true - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: agent-output path: /tmp/gh-aw/safeoutputs/ diff --git a/.github/workflows/nodejs-sdk-tests.yml b/.github/workflows/nodejs-sdk-tests.yml index 5947396d0..9e978a22f 100644 --- a/.github/workflows/nodejs-sdk-tests.yml +++ b/.github/workflows/nodejs-sdk-tests.yml @@ -12,6 +12,7 @@ on: - 'nodejs/**' - 'test/**' - '.github/workflows/nodejs-sdk-tests.yml' + - '!nodejs/scripts/**' - '!**/*.md' - '!**/LICENSE*' - '!**/.gitignore' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a3849d62c..6add87e28 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,6 +14,7 @@ on: options: - latest - prerelease + - unstable version: description: "Version override (optional, e.g., 1.0.0). If empty, auto-increments." type: string @@ -22,6 +23,7 @@ on: permissions: contents: write id-token: write # Required for OIDC + actions: write # Required to trigger changelog workflow concurrency: group: publish @@ -65,8 +67,8 @@ jobs: fi else if [[ "$VERSION" != *-* ]]; then - echo "❌ Error: Version '$VERSION' has no prerelease suffix but dist-tag is 'prerelease'" >> $GITHUB_STEP_SUMMARY - echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease" + echo "❌ Error: Version '$VERSION' has no prerelease suffix but dist-tag is '${{ github.event.inputs.dist-tag }}'" >> $GITHUB_STEP_SUMMARY + echo "Use a version with suffix (e.g., '1.0.0-preview.0') for prerelease/unstable" exit 1 fi fi @@ -101,16 +103,17 @@ jobs: - name: Pack run: npm pack - name: Upload artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7.0.0 with: name: nodejs-package path: nodejs/*.tgz - name: Publish to npm - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' || github.event.inputs.dist-tag == 'unstable' run: npm publish --tag ${{ github.event.inputs.dist-tag }} --access public --registry https://registry.npmjs.org publish-dotnet: name: Publish .NET SDK + if: github.event.inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest defaults: @@ -120,13 +123,13 @@ jobs: - uses: actions/checkout@v6.0.2 - uses: actions/setup-dotnet@v5 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Restore dependencies run: dotnet restore - name: Build and pack run: dotnet pack src/GitHub.Copilot.SDK.csproj -c Release -p:Version=${{ needs.version.outputs.version }} -o ./artifacts - name: Upload artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7.0.0 with: name: dotnet-package path: dotnet/artifacts/*.nupkg @@ -146,6 +149,7 @@ jobs: publish-python: name: Publish Python SDK + if: github.event.inputs.dist-tag != 'unstable' needs: version runs-on: ubuntu-latest defaults: @@ -169,7 +173,7 @@ jobs: - name: Build platform wheels run: node scripts/build-wheels.mjs --output-dir dist - name: Upload artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7.0.0 with: name: python-package path: python/dist/* @@ -182,7 +186,7 @@ jobs: github-release: name: Create GitHub Release needs: [version, publish-nodejs, publish-dotnet, publish-python] - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' && github.event.inputs.dist-tag != 'unstable' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6.0.2 @@ -213,6 +217,10 @@ jobs: --target ${{ github.sha }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Trigger changelog generation + run: gh workflow run release-changelog.lock.yml -f tag="v${{ needs.version.outputs.version }}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Tag Go SDK submodule if: github.event.inputs.dist-tag == 'latest' || github.event.inputs.dist-tag == 'prerelease' run: | diff --git a/.github/workflows/python-sdk-tests.yml b/.github/workflows/python-sdk-tests.yml index 079395b32..941f08183 100644 --- a/.github/workflows/python-sdk-tests.yml +++ b/.github/workflows/python-sdk-tests.yml @@ -37,6 +37,8 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] + # Test the oldest supported Python version to make sure compatibility is maintained. + python-version: ["3.11"] runs-on: ${{ matrix.os }} defaults: run: @@ -46,7 +48,7 @@ jobs: - uses: actions/checkout@v6.0.2 - uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} - uses: actions/setup-node@v6 with: node-version: "22" diff --git a/.github/workflows/release-changelog.lock.yml b/.github/workflows/release-changelog.lock.yml new file mode 100644 index 000000000..e85e0f3ed --- /dev/null +++ b/.github/workflows/release-changelog.lock.yml @@ -0,0 +1,1185 @@ +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.52.1). DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Generates release notes from merged PRs/commits. Triggered by the publish workflow or manually via workflow_dispatch. +# +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"c06cce5802b74e1280963eef2e92515d84870d76d9cfdefa84b56c038e2b8da1","compiler_version":"v0.52.1"} + +name: "Release Changelog Generator" +"on": + workflow_dispatch: + inputs: + tag: + description: Release tag to generate changelog for (e.g., v0.1.30) + required: true + type: string + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}" + +run-name: "Release Changelog Generator" + +jobs: + activation: + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + comment_id: "" + comment_repo: "" + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@8c53fd1f95aad591c003b39360b2ec16237b373f # v0.53.0 + with: + destination: /opt/gh-aw/actions + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} + GH_AW_INFO_VERSION: "" + GH_AW_INFO_AGENT_VERSION: "0.0.420" + GH_AW_INFO_CLI_VERSION: "v0.52.1" + GH_AW_INFO_WORKFLOW_NAME: "Release Changelog Generator" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.23.0" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { main } = require('/opt/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + .github + .agents + sparse-checkout-cone-mode: true + fetch-depth: 1 + persist-credentials: false + - name: Check workflow file timestamps + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_FILE: "release-changelog.lock.yml" + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_INPUTS_TAG: ${{ github.event.inputs.tag }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + { + cat << 'GH_AW_PROMPT_EOF' + + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/xpia.md" + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" + cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_EOF' + + Tools: create_pull_request, update_release, missing_tool, missing_data, noop + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/safe_outputs_create_pull_request.md" + cat << 'GH_AW_PROMPT_EOF' + + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/release-changelog.md}} + GH_AW_PROMPT_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_EVENT_INPUTS_TAG: ${{ github.event.inputs.tag }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_INPUTS_TAG: ${{ github.event.inputs.tag }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_INPUTS_TAG: process.env.GH_AW_GITHUB_EVENT_INPUTS_TAG, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: activation + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/aw-prompts/prompt.txt + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + issues: read + pull-requests: read + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_WORKFLOW_ID_SANITIZED: releasechangelog + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }} + model: ${{ needs.activation.outputs.model }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@8c53fd1f95aad591c003b39360b2ec16237b373f # v0.53.0 + with: + destination: /opt/gh-aw/actions + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + (github.event.pull_request) || (github.event.issue.pull_request) + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Install GitHub Copilot CLI + run: /opt/gh-aw/actions/install_copilot_cli.sh 0.0.420 + - name: Install awf binary + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.23.0 + - name: Determine automatic lockdown mode for GitHub MCP Server + id: determine-automatic-lockdown + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + with: + script: | + const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); + - name: Download container images + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.23.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.23.0 ghcr.io/github/gh-aw-firewall/squid:0.23.0 ghcr.io/github/gh-aw-mcpg:v0.1.7 ghcr.io/github/github-mcp-server:v0.31.0 node:lts-alpine + - name: Write Safe Outputs Config + run: | + mkdir -p /opt/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' + {"create_pull_request":{"max":1,"title_prefix":"[changelog] "},"missing_data":{},"missing_tool":{},"noop":{"max":1},"update_release":{"max":1}} + GH_AW_SAFE_OUTPUTS_CONFIG_EOF + cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' + [ + { + "description": "Create a new GitHub pull request to propose code changes. Use this after making file edits to submit them for review and merging. The PR will be created from the current branch with your committed changes. For code review comments on an existing PR, use create_pull_request_review_comment instead. CONSTRAINTS: Maximum 1 pull request(s) can be created. Title will be prefixed with \"[changelog] \". Labels [automation changelog] will be automatically added.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Detailed PR description in Markdown. Include what changes were made, why, testing notes, and any breaking changes. Do NOT repeat the title as a heading.", + "type": "string" + }, + "branch": { + "description": "Source branch name containing the changes. If omitted, uses the current working branch.", + "type": "string" + }, + "draft": { + "description": "Whether to create the PR as a draft. Draft PRs cannot be merged until marked as ready for review. Use mark_pull_request_as_ready_for_review to convert a draft PR. Default: true.", + "type": "boolean" + }, + "labels": { + "description": "Labels to categorize the PR (e.g., 'enhancement', 'bugfix'). Labels must exist in the repository.", + "items": { + "type": "string" + }, + "type": "array" + }, + "repo": { + "description": "Target repository in 'owner/repo' format. For multi-repo workflows where the target repo differs from the workflow repo, this must match a repo in the allowed-repos list or the configured target-repo. If omitted, defaults to the configured target-repo (from safe-outputs config), NOT the workflow repository. In most cases, you should omit this parameter and let the system use the configured default.", + "type": "string" + }, + "title": { + "description": "Concise PR title describing the changes. Follow repository conventions (e.g., conventional commits). The title appears as the main heading.", + "type": "string" + } + }, + "required": [ + "title", + "body" + ], + "type": "object" + }, + "name": "create_pull_request" + }, + { + "description": "Update a GitHub release description by replacing, appending to, or prepending to the existing content. Use this to add release notes, changelogs, or additional information to an existing release. CONSTRAINTS: Maximum 1 release(s) can be updated.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Release body content in Markdown. For 'replace', this becomes the entire release body. For 'append'/'prepend', this is added with a separator.", + "type": "string" + }, + "operation": { + "description": "How to update the release body: 'replace' (completely overwrite), 'append' (add to end with separator), or 'prepend' (add to start with separator).", + "enum": [ + "replace", + "append", + "prepend" + ], + "type": "string" + }, + "tag": { + "description": "Release tag name (e.g., 'v1.0.0'). REQUIRED - must be provided explicitly as the tag cannot always be inferred from event context.", + "type": "string" + } + }, + "required": [ + "tag", + "operation", + "body" + ], + "type": "object" + }, + "name": "update_release" + }, + { + "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "reason": { + "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).", + "type": "string" + }, + "tool": { + "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", + "type": "string" + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "name": "missing_tool" + }, + { + "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "name": "noop" + }, + { + "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "context": { + "description": "Additional context about the missing data or where it should come from (max 256 characters).", + "type": "string" + }, + "data_type": { + "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.", + "type": "string" + }, + "reason": { + "description": "Explanation of why this data is needed to complete the task (max 256 characters).", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "missing_data" + } + ] + GH_AW_SAFE_OUTPUTS_TOOLS_EOF + cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' + { + "create_pull_request": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "draft": { + "type": "boolean" + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "update_release": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "operation": { + "required": true, + "type": "string", + "enum": [ + "replace", + "append", + "prepend" + ] + }, + "tag": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + } + } + GH_AW_SAFE_OUTPUTS_VALIDATION_EOF + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash /opt/gh-aw/actions/start_safe_outputs_server.sh + + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.7' + + mkdir -p /home/runner/.copilot + cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v0.31.0", + "env": { + "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", + "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + } + }, + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_EOF + - name: Download activation artifact + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: activation + path: /tmp/gh-aw + - name: Clean git credentials + run: bash /opt/gh-aw/actions/clean_git_credentials.sh + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 15 + run: | + set -o pipefail + # shellcheck disable=SC1003 + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_MODEL_AGENT_COPILOT: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + GITHUB_WORKSPACE: ${{ github.workspace }} + XDG_CONFIG_HOME: /home/runner + - name: Detect inference access error + id: detect-inference-error + if: always() + continue-on-error: true + run: bash /opt/gh-aw/actions/detect_inference_access_error.sh + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: | + # Copy Copilot session state files to logs folder for artifact collection + # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them + SESSION_STATE_DIR="$HOME/.copilot/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + + if [ -d "$SESSION_STATE_DIR" ]; then + echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" + mkdir -p "$LOGS_DIR" + cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true + echo "Session state files copied successfully" + else + echo "No session-state directory found at $SESSION_STATE_DIR" + fi + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload Safe Outputs + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: safe-output + path: ${{ env.GH_AW_SAFE_OUTPUTS }} + if-no-files-found: warn + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Upload sanitized agent output + if: always() && env.GH_AW_AGENT_OUTPUT + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: agent-output + path: ${{ env.GH_AW_AGENT_OUTPUT }} + if-no-files-found: warn + - name: Upload engine output files + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: agent_outputs + path: | + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + if-no-files-found: ignore + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: agent-artifacts + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + /tmp/gh-aw/aw-*.patch + if-no-files-found: ignore + # --- Threat Detection (inline) --- + - name: Check if detection needed + id: detection_guard + if: always() + env: + OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} + HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + run: | + if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then + echo "run_detection=true" >> "$GITHUB_OUTPUT" + echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" + else + echo "run_detection=false" >> "$GITHUB_OUTPUT" + echo "Detection skipped: no agent outputs or patches to analyze" + fi + - name: Clear MCP configuration for detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + rm -f /tmp/gh-aw/mcp-config/mcp-servers.json + rm -f /home/runner/.copilot/mcp-config.json + rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" + - name: Prepare threat detection files + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection/aw-prompts + cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true + cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true + for f in /tmp/gh-aw/aw-*.patch; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + echo "Prepared threat detection files:" + ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + - name: Setup threat detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + WORKFLOW_NAME: "Release Changelog Generator" + WORKFLOW_DESCRIPTION: "Generates release notes from merged PRs/commits. Triggered by the publish workflow or manually via workflow_dispatch." + HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Execute GitHub Copilot CLI + if: always() && steps.detection_guard.outputs.run_detection == 'true' + id: detection_agentic_execution + # Copilot CLI tool arguments (sorted): + # --allow-tool shell(cat) + # --allow-tool shell(grep) + # --allow-tool shell(head) + # --allow-tool shell(jq) + # --allow-tool shell(ls) + # --allow-tool shell(tail) + # --allow-tool shell(wc) + timeout-minutes: 20 + run: | + set -o pipefail + # shellcheck disable=SC1003 + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_DETECTION_COPILOT:+ --model "$GH_AW_MODEL_DETECTION_COPILOT"}' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_MODEL_DETECTION_COPILOT: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + GITHUB_WORKSPACE: ${{ github.workspace }} + XDG_CONFIG_HOME: /home/runner + - name: Parse threat detection results + id: parse_detection_results + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + - name: Upload threat detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: threat-detection.log + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + - name: Set detection conclusion + id: detection_conclusion + if: always() + env: + RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} + DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} + run: | + if [[ "$RUN_DETECTION" != "true" ]]; then + echo "conclusion=skipped" >> "$GITHUB_OUTPUT" + echo "success=true" >> "$GITHUB_OUTPUT" + echo "Detection was not needed, marking as skipped" + elif [[ "$DETECTION_SUCCESS" == "true" ]]; then + echo "conclusion=success" >> "$GITHUB_OUTPUT" + echo "success=true" >> "$GITHUB_OUTPUT" + echo "Detection passed successfully" + else + echo "conclusion=failure" >> "$GITHUB_OUTPUT" + echo "success=false" >> "$GITHUB_OUTPUT" + echo "Detection found issues" + fi + + conclusion: + needs: + - activation + - agent + - safe_outputs + if: (always()) && (needs.agent.result != 'skipped') + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + outputs: + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@8c53fd1f95aad591c003b39360b2ec16237b373f # v0.53.0 + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process No-Op Messages + id: noop + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Release Changelog Generator" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/noop.cjs'); + await main(); + - name: Record Missing Tool + id: missing_tool + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Release Changelog Generator" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Handle Agent Failure + id: handle_agent_failure + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Release Changelog Generator" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "release-changelog" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_CODE_PUSH_FAILURE_ERRORS: ${{ needs.safe_outputs.outputs.code_push_failure_errors }} + GH_AW_CODE_PUSH_FAILURE_COUNT: ${{ needs.safe_outputs.outputs.code_push_failure_count }} + GH_AW_GROUP_REPORTS: "false" + GH_AW_TIMEOUT_MINUTES: "15" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + - name: Handle No-Op Message + id: handle_noop_message + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Release Changelog Generator" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Handle Create Pull Request Error + id: handle_create_pr_error + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Release Changelog Generator" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_create_pr_error.cjs'); + await main(); + + safe_outputs: + needs: + - activation + - agent + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + timeout-minutes: 15 + env: + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/release-changelog" + GH_AW_ENGINE_ID: "copilot" + GH_AW_WORKFLOW_ID: "release-changelog" + GH_AW_WORKFLOW_NAME: "Release Changelog Generator" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + created_pr_number: ${{ steps.process_safe_outputs.outputs.created_pr_number }} + created_pr_url: ${{ steps.process_safe_outputs.outputs.created_pr_url }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@8c53fd1f95aad591c003b39360b2ec16237b373f # v0.53.0 + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Download patch artifact + continue-on-error: true + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: agent-artifacts + path: /tmp/gh-aw/ + - name: Checkout repository + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }} + token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + persist-credentials: false + fetch-depth: 1 + - name: Configure Git credentials + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GIT_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_pull_request\":{\"draft\":false,\"labels\":[\"automation\",\"changelog\"],\"max\":1,\"max_patch_size\":1024,\"title_prefix\":\"[changelog] \"},\"missing_data\":{},\"missing_tool\":{},\"update_release\":{\"max\":1}}" + GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + - name: Upload safe output items manifest + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: safe-output-items + path: /tmp/safe-output-items.jsonl + if-no-files-found: warn + diff --git a/.github/workflows/release-changelog.md b/.github/workflows/release-changelog.md new file mode 100644 index 000000000..30e700dec --- /dev/null +++ b/.github/workflows/release-changelog.md @@ -0,0 +1,169 @@ +--- +description: Generates release notes from merged PRs/commits. Triggered by the publish workflow or manually via workflow_dispatch. +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to generate changelog for (e.g., v0.1.30)" + required: true + type: string +permissions: + contents: read + actions: read + issues: read + pull-requests: read +tools: + github: + toolsets: [default] + edit: +safe-outputs: + create-pull-request: + title-prefix: "[changelog] " + labels: [automation, changelog] + draft: false + update-release: + max: 1 +timeout-minutes: 15 +--- + +# Release Changelog Generator + +You are an AI agent that generates well-formatted release notes when a release of the Copilot SDK is published. + +- **For stable releases** (tag has no prerelease suffix like `-preview`): update `CHANGELOG.md` via a PR AND update the GitHub Release notes. +- **For prerelease releases** (tag contains `-preview` or similar suffix): update the GitHub Release notes ONLY. Do NOT modify `CHANGELOG.md` or create a PR. + +Determine which type of release this is by inspecting the tag or fetching the release metadata. + +## Context + +- Repository: ${{ github.repository }} +- Release tag: ${{ github.event.inputs.tag }} + +Use the GitHub API to fetch the release corresponding to `${{ github.event.inputs.tag }}` to get its name, publish date, prerelease status, and other metadata. + +## Your Task + +### Step 1: Identify the version range + +1. The **new version** is the release tag: `${{ github.event.inputs.tag }}` +2. Fetch the release metadata to determine if this is a **stable** or **prerelease** release. +3. Determine the **previous version** to diff against: + - **For stable releases**: find the previous **stable** release (skip prereleases). Check `CHANGELOG.md` for the most recent version heading (`## [vX.Y.Z](...)`), or fall back to listing releases via the API. This means stable changelogs include ALL changes since the last stable release, even if some were already mentioned in prerelease notes. + - **For prerelease releases**: find the most recent release of **any kind** (stable or prerelease) that precedes this one. This way prerelease notes only cover what's new since the last release. +4. If no previous release exists at all, use the first commit in the repo as the starting point. + +### Step 2: Gather changes + +1. Use the GitHub tools to list commits between the last documented tag (from Step 1) and the new release tag. +2. Also list merged pull requests in that range. For each PR, note: + - PR number and title + - The PR author + - Which SDK(s) were affected (look for prefixes like `[C#]`, `[Python]`, `[Go]`, `[Node]` in the title, or infer from changed files) +3. Ignore: + - Dependabot/bot PRs that only bump internal dependencies (like `Update @github/copilot to ...`) unless they bring user-facing changes + - Merge commits with no meaningful content + - Preview/prerelease-only changes that were already documented + +### Step 3: Categorize and write up + +Separate the changes into two groups: + +1. **Highlighted features**: Any interesting new feature or significant improvement that deserves its own section with a description and code snippet(s). Read the PR diff and source code to understand the feature well enough to write about it. +2. **Other changes**: Bug fixes, minor improvements, and smaller features that can be summarized in a single bullet each. + +Only include changes that are **user-visible in the published SDK packages**. Skip anything that only affects docs, CI, build tooling, GitHub workflows, test infrastructure, or other internal-only concerns. + +Additionally, identify **new contributors** — anyone whose first merged PR to this repo falls within this release range. You can determine this by checking whether the author has any earlier merged PRs in the repository. + +### Step 4: Update CHANGELOG.md (stable releases only) + +**Skip this step entirely for prerelease releases.** + +1. Read the current `CHANGELOG.md` file. +2. Add the new version entry **at the top** of the file, right after the title/header. + +**Format for each highlighted feature** — use an `### Feature:` or `### Fix:` heading, a 1-2 sentence description explaining what it does and why it matters, and at least one short code snippet (max 3 lines). Focus on **TypeScript** and **C#** as the primary languages. Only show Go/Python when giving a list of one-liner equivalents across all languages, or when their usage pattern is meaningfully different. + +**Format for other changes** — a single `### Other changes` section with a flat bulleted list. Each bullet has a lowercase prefix (`feature:`, `bugfix:`, `improvement:`) and a one-line description linking to the PR. **However, if there are no highlighted features above it, omit the `### Other changes` heading entirely** — just list the bullets directly under the version heading. + +3. Use the release's publish date (from the GitHub Release metadata), not today's date. For `workflow_dispatch` runs, fetch the release by tag to get the date. +4. If there are new contributors, add a `### New contributors` section at the end listing each with a link to their first PR: + ``` + ### New contributors + - @username made their first contribution in [#123](https://github.com/github/copilot-sdk/pull/123) + ``` + Omit this section if there are no new contributors. +5. Make sure the existing content below is preserved exactly as-is. + +### Step 5: Create a Pull Request (stable releases only) + +**Skip this step entirely for prerelease releases.** + +Use the `create-pull-request` output to submit your changes. The PR should: +- Have a clear title like "Add changelog for vX.Y.Z" +- Include a brief body summarizing the number of changes + +### Step 6: Update the GitHub Release + +Use the `update-release` output to replace the auto-generated release notes with your nicely formatted changelog. **Do not include the version heading** (`## [vX.Y.Z](...) (date)`) in the release notes — the release already has a title showing the version. Start directly with the feature sections or other changes list. + +## Example Output + +Here is an example of what a changelog entry should look like, based on real commits from this repo. **Follow this style exactly.** + +````markdown +## [v0.1.28](https://github.com/github/copilot-sdk/releases/tag/v0.1.28) (2026-02-14) + +### Feature: support overriding built-in tools + +Applications can now override built-in tools such as `edit` or `grep`. To do this, register a custom tool with the same name and set the override flag. ([#636](https://github.com/github/copilot-sdk/pull/636)) + +```ts +session.defineTool("edit", { isOverride: true }, async (params) => { + // custom edit implementation +}); +``` + +```cs +session.DefineTool("edit", new ToolOptions { IsOverride = true }, async (params) => { + // custom edit implementation +}); +``` + +### Feature: simpler API for changing model mid-session + +While `session.rpc.models.setModel()` already worked, there is now a convenience method directly on the session object. ([#621](https://github.com/github/copilot-sdk/pull/621)) + +- TypeScript: `session.setModel("gpt-4o")` +- C#: `session.SetModel("gpt-4o")` +- Python: `session.set_model("gpt-4o")` +- Go: `session.SetModel("gpt-4o")` + +### Other changes + +- bugfix: **[Python]** correct `PermissionHandler.approve_all` type annotations ([#618](https://github.com/github/copilot-sdk/pull/618)) +- improvement: **[C#]** use event delegate for thread-safe, insertion-ordered event handler dispatch ([#624](https://github.com/github/copilot-sdk/pull/624)) +- improvement: **[C#]** deduplicate `OnDisposeCall` and improve implementation ([#626](https://github.com/github/copilot-sdk/pull/626)) +- improvement: **[C#]** remove unnecessary `SemaphoreSlim` locks for handler fields ([#625](https://github.com/github/copilot-sdk/pull/625)) + +### New contributors + +- @chlowell made their first contribution in [#586](https://github.com/github/copilot-sdk/pull/586) +- @feici02 made their first contribution in [#566](https://github.com/github/copilot-sdk/pull/566) +```` + +**Key rules visible in the example:** +- Highlighted features get their own `### Feature:` heading, a short description, and code snippets +- Code snippets are TypeScript and C# primarily; Go/Python only when listing one-liner equivalents or when meaningfully different +- The `### Other changes` section is a flat bulleted list with lowercase `bugfix:` / `feature:` / `improvement:` prefixes +- PR numbers are linked inline, not at the end with author attribution (keep it clean) + +## Guidelines + +1. **Be concise**: Each bullet should be one short sentence. Don't over-explain. +2. **Be accurate**: Only include changes that actually landed in this release range. Don't hallucinate PRs. +3. **Attribute correctly**: Always link to the PR number. Do not add explicit author attribution. +4. **Skip noise**: Don't include trivial changes (typo fixes in comments, whitespace changes) unless they're the only changes. +5. **Preserve history**: Never modify existing entries in CHANGELOG.md — only prepend new ones. +6. **Handle edge cases**: If there are no meaningful changes (e.g., only internal dependency bumps), still create an entry noting "Internal dependency updates only" or similar. diff --git a/.github/workflows/scenario-builds.yml b/.github/workflows/scenario-builds.yml index a66ede5ec..54d7257e5 100644 --- a/.github/workflows/scenario-builds.yml +++ b/.github/workflows/scenario-builds.yml @@ -152,7 +152,7 @@ jobs: - uses: actions/setup-dotnet@v5 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - uses: actions/cache@v4 with: diff --git a/.github/workflows/sdk-consistency-review.lock.yml b/.github/workflows/sdk-consistency-review.lock.yml index 518ba9ca5..3b5ff5fe0 100644 --- a/.github/workflows/sdk-consistency-review.lock.yml +++ b/.github/workflows/sdk-consistency-review.lock.yml @@ -59,7 +59,7 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@v0.40.0 + uses: githubnext/gh-aw/actions/setup@v0.53.0 with: destination: /opt/gh-aw/actions - name: Check workflow file timestamps @@ -97,7 +97,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@v0.40.0 + uses: githubnext/gh-aw/actions/setup@v0.53.0 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -787,7 +787,7 @@ jobs: SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload Safe Outputs if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: safe-output path: ${{ env.GH_AW_SAFE_OUTPUTS }} @@ -808,13 +808,13 @@ jobs: await main(); - name: Upload sanitized agent output if: always() && env.GH_AW_AGENT_OUTPUT - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: agent-output path: ${{ env.GH_AW_AGENT_OUTPUT }} if-no-files-found: warn - name: Upload engine output files - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: agent_outputs path: | @@ -854,7 +854,7 @@ jobs: - name: Upload agent artifacts if: always() continue-on-error: true - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: agent-artifacts path: | @@ -884,7 +884,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@v0.40.0 + uses: githubnext/gh-aw/actions/setup@v0.53.0 with: destination: /opt/gh-aw/actions - name: Debug job inputs @@ -900,7 +900,7 @@ jobs: echo "Agent Conclusion: $AGENT_CONCLUSION" - name: Download agent output artifact continue-on-error: true - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: agent-output path: /tmp/gh-aw/safeoutputs/ @@ -981,18 +981,18 @@ jobs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@v0.40.0 + uses: githubnext/gh-aw/actions/setup@v0.53.0 with: destination: /opt/gh-aw/actions - name: Download agent artifacts continue-on-error: true - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: agent-artifacts path: /tmp/gh-aw/threat-detection/ - name: Download agent output artifact continue-on-error: true - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: agent-output path: /tmp/gh-aw/threat-detection/ @@ -1106,7 +1106,7 @@ jobs: await main(); - name: Upload threat detection log if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: threat-detection.log path: /tmp/gh-aw/threat-detection/detection.log @@ -1133,12 +1133,12 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: githubnext/gh-aw/actions/setup@v0.40.0 + uses: githubnext/gh-aw/actions/setup@v0.53.0 with: destination: /opt/gh-aw/actions - name: Download agent output artifact continue-on-error: true - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: agent-output path: /tmp/gh-aw/safeoutputs/ diff --git a/.github/workflows/update-copilot-dependency.yml b/.github/workflows/update-copilot-dependency.yml new file mode 100644 index 000000000..b1d3cae6d --- /dev/null +++ b/.github/workflows/update-copilot-dependency.yml @@ -0,0 +1,117 @@ +name: "Update @github/copilot Dependency" + +on: + workflow_dispatch: + inputs: + version: + description: 'Target version of @github/copilot (e.g. 0.0.420)' + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + update: + name: "Update @github/copilot to ${{ inputs.version }}" + runs-on: ubuntu-latest + steps: + - name: Validate version input + env: + VERSION: ${{ inputs.version }} + run: | + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$ ]]; then + echo "::error::Invalid version format '$VERSION'. Expected semver (e.g. 0.0.420)." + exit 1 + fi + + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Update @github/copilot in nodejs + env: + VERSION: ${{ inputs.version }} + working-directory: ./nodejs + run: npm install "@github/copilot@$VERSION" + + - name: Update @github/copilot in test harness + env: + VERSION: ${{ inputs.version }} + working-directory: ./test/harness + run: npm install "@github/copilot@$VERSION" + + - name: Refresh nodejs/samples lockfile + working-directory: ./nodejs/samples + run: npm install + + - name: Install codegen dependencies + working-directory: ./scripts/codegen + run: npm ci + + - name: Run codegen + working-directory: ./scripts/codegen + run: npm run generate + + - name: Format generated code + run: | + cd nodejs && npx prettier --write "src/generated/**/*.ts" + cd ../dotnet && dotnet format src/GitHub.Copilot.SDK.csproj + + - name: Create pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.version }} + run: | + BRANCH="update-copilot-$VERSION" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then + git checkout "$BRANCH" + git reset --hard HEAD + else + git checkout -b "$BRANCH" + fi + + git add -A + + if git diff --cached --quiet; then + echo "No changes detected; skipping commit and PR creation." + exit 0 + fi + + git commit -m "Update @github/copilot to $VERSION + + - Updated nodejs and test harness dependencies + - Re-ran code generators + - Formatted generated code" + git push origin "$BRANCH" --force-with-lease + + if gh pr view "$BRANCH" >/dev/null 2>&1; then + echo "Pull request for branch '$BRANCH' already exists; updated branch only." + else + gh pr create \ + --title "Update @github/copilot to $VERSION" \ + --body "Automated update of \`@github/copilot\` to version \`$VERSION\`. + + ### Changes + - Updated \`@github/copilot\` in \`nodejs/package.json\` and \`test/harness/package.json\` + - Re-ran all code generators (\`scripts/codegen\`) + - Formatted generated output + + > Created by the **Update @github/copilot Dependency** workflow." \ + --base main \ + --head "$BRANCH" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..5abbfefc4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +# Changelog + +All notable changes to the Copilot SDK are documented in this file. + +This changelog is automatically generated by an AI agent when stable releases are published. +See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the full list. + +## [v0.1.30](https://github.com/github/copilot-sdk/releases/tag/v0.1.30) (2026-03-03) + +### Feature: support overriding built-in tools + +Applications can now override built-in tools such as `grep`, `edit_file`, or `read_file`. To do this, register a custom tool with the same name and set the override flag. Without the flag, the runtime will return an error if the name clashes with a built-in. ([#636](https://github.com/github/copilot-sdk/pull/636)) + +```ts +import { defineTool } from "@github/copilot-sdk"; + +const session = await client.createSession({ + tools: [defineTool("grep", { + overridesBuiltInTool: true, + handler: async (params) => `CUSTOM_GREP_RESULT: ${params.query}`, + })], + onPermissionRequest: approveAll, +}); +``` + +```cs +var grep = AIFunctionFactory.Create( + ([Description("Search query")] string query) => $"CUSTOM_GREP_RESULT: {query}", + "grep", + "Custom grep implementation", + new AIFunctionFactoryOptions + { + AdditionalProperties = new ReadOnlyDictionary( + new Dictionary { ["is_override"] = true }) + }); +``` + +### Feature: simpler API for changing model mid-session + +While `session.rpc.model.switchTo()` already worked, there is now a convenience method directly on the session object. ([#621](https://github.com/github/copilot-sdk/pull/621)) + +- TypeScript: `await session.setModel("gpt-4.1")` +- C#: `await session.SetModelAsync("gpt-4.1")` +- Python: `await session.set_model("gpt-4.1")` +- Go: `err := session.SetModel(ctx, "gpt-4.1")` + +### Other changes + +- improvement: **[C#]** use event delegate for thread-safe, insertion-ordered event handler dispatch ([#624](https://github.com/github/copilot-sdk/pull/624)) +- improvement: **[C#]** deduplicate `OnDisposeCall` and improve implementation ([#626](https://github.com/github/copilot-sdk/pull/626)) +- improvement: **[C#]** remove unnecessary `SemaphoreSlim` locks for handler fields ([#625](https://github.com/github/copilot-sdk/pull/625)) +- bugfix: **[Python]** correct `PermissionHandler.approve_all` type annotations ([#618](https://github.com/github/copilot-sdk/pull/618)) + +### New contributors + +- @giulio-leone made their first contribution in [#618](https://github.com/github/copilot-sdk/pull/618) diff --git a/docs/auth/byok.md b/docs/auth/byok.md index b244c4532..ca7861c16 100644 --- a/docs/auth/byok.md +++ b/docs/auth/byok.md @@ -10,6 +10,7 @@ BYOK allows you to use the Copilot SDK with your own API keys from model provide | Azure OpenAI / Azure AI Foundry | `"azure"` | Azure-hosted models | | Anthropic | `"anthropic"` | Claude models | | Ollama | `"openai"` | Local models via OpenAI-compatible API | +| Microsoft Foundry Local | `"openai"` | Run AI models locally on your device via OpenAI-compatible API | | Other OpenAI-compatible | `"openai"` | vLLM, LiteLLM, etc. | ## Quick Start: Azure AI Foundry @@ -53,7 +54,7 @@ async def main(): await session.send({"prompt": "What is 2+2?"}) await done.wait() - await session.destroy() + await session.disconnect() await client.stop() asyncio.run(main()) @@ -250,6 +251,37 @@ provider: { } ``` +### Microsoft Foundry Local + +[Microsoft Foundry Local](https://foundrylocal.ai) lets you run AI models locally on your own device with an OpenAI-compatible API. Install it via the Foundry Local CLI, then point the SDK at your local endpoint: + +```typescript +provider: { + type: "openai", + baseUrl: "http://localhost:/v1", + // No apiKey needed for local Foundry Local +} +``` + +> **Note:** Foundry Local starts on a **dynamic port** — the port is not fixed. Use `foundry service status` to confirm the port the service is currently listening on, then use that port in your `baseUrl`. + +To get started with Foundry Local: + +```bash +# Windows: Install Foundry Local CLI (requires winget) +winget install Microsoft.FoundryLocal + +# macOS / Linux: see https://foundrylocal.ai for installation instructions +# List available models +foundry model list + +# Run a model (starts the local server automatically) +foundry model run phi-4-mini + +# Check the port the service is running on +foundry service status +``` + ### Anthropic ```typescript @@ -305,6 +337,7 @@ Some Copilot features may behave differently with BYOK: |----------|-------------| | Azure AI Foundry | No Entra ID auth; must use API keys | | Ollama | No API key; local only; model support varies | +| [Microsoft Foundry Local](https://foundrylocal.ai) | Local only; model availability depends on device hardware; no API key required | | OpenAI | Subject to OpenAI rate limits and quotas | ## Troubleshooting @@ -368,6 +401,21 @@ curl http://localhost:11434/v1/models ollama serve ``` +### Connection Refused (Foundry Local) + +Foundry Local uses a dynamic port that may change between restarts. Confirm the active port: + +```bash +# Check the service status and port +foundry service status +``` + +Update your `baseUrl` to match the port shown in the output. If the service is not running, start a model to launch it: + +```bash +foundry model run phi-4-mini +``` + ### Authentication Failed 1. Verify your API key is correct and not expired diff --git a/docs/compatibility.md b/docs/compatibility.md index 268c077a3..bfd17915b 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -15,7 +15,8 @@ The Copilot SDK communicates with the CLI via JSON-RPC protocol. Features must b | **Session Management** | | | | Create session | `createSession()` | Full config support | | Resume session | `resumeSession()` | With infinite session workspaces | -| Destroy session | `destroy()` | Clean up resources | +| Disconnect session | `disconnect()` | Release in-memory resources | +| Destroy session *(deprecated)* | `destroy()` | Use `disconnect()` instead | | Delete session | `deleteSession()` | Remove from storage | | List sessions | `listSessions()` | All stored sessions | | Get last session | `getLastSessionId()` | For quick resume | diff --git a/docs/debugging.md b/docs/debugging.md index 6183cccdf..bf953b2ff 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -248,9 +248,9 @@ var client = new CopilotClient(new CopilotClientOptions **Solution:** -1. Ensure you're not calling methods after `destroy()`: +1. Ensure you're not calling methods after `disconnect()`: ```typescript - await session.destroy(); + await session.disconnect(); // Don't use session after this! ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index f615e923b..05bbde8dc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -426,7 +426,32 @@ unsubscribeIdle();
Python - + +```python +from copilot import CopilotClient +from copilot.generated.session_events import SessionEvent, SessionEventType + +client = CopilotClient() + +session = client.create_session({"on_permission_request": lambda req, inv: {"kind": "approved"}}) + +# Subscribe to all events +unsubscribe = session.on(lambda event: print(f"Event: {event.type}")) + +# Filter by event type in your handler +def handle_event(event: SessionEvent) -> None: + if event.type == SessionEventType.SESSION_IDLE: + print("Session is idle") + elif event.type == SessionEventType.ASSISTANT_MESSAGE: + print(f"Message: {event.data.content}") + +unsubscribe = session.on(handle_event) + +# Later, to unsubscribe: +unsubscribe() +``` + + ```python # Subscribe to all events unsubscribe = session.on(lambda event: print(f"Event: {event.type}")) @@ -449,7 +474,39 @@ unsubscribe()
Go - + +```go +package main + +import ( + "fmt" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + session := &copilot.Session{} + + // Subscribe to all events + unsubscribe := session.On(func(event copilot.SessionEvent) { + fmt.Println("Event:", event.Type) + }) + + // Filter by event type in your handler + session.On(func(event copilot.SessionEvent) { + if event.Type == "session.idle" { + fmt.Println("Session is idle") + } else if event.Type == "assistant.message" { + fmt.Println("Message:", *event.Data.Content) + } + }) + + // Later, to unsubscribe: + unsubscribe() +} +``` + + ```go // Subscribe to all events unsubscribe := session.On(func(event copilot.SessionEvent) { @@ -474,7 +531,38 @@ unsubscribe()
.NET - + +```csharp +using GitHub.Copilot.SDK; + +public static class EventSubscriptionExample +{ + public static void Example(CopilotSession session) + { + // Subscribe to all events + var unsubscribe = session.On(ev => Console.WriteLine($"Event: {ev.Type}")); + + // Filter by event type using pattern matching + session.On(ev => + { + switch (ev) + { + case SessionIdleEvent: + Console.WriteLine("Session is idle"); + break; + case AssistantMessageEvent msg: + Console.WriteLine($"Message: {msg.Data.Content}"); + break; + } + }); + + // Later, to unsubscribe: + unsubscribe.Dispose(); + } +} +``` + + ```csharp // Subscribe to all events var unsubscribe = session.On(ev => Console.WriteLine($"Event: {ev.Type}")); @@ -1193,14 +1281,14 @@ Once the CLI is running in server mode, configure your SDK client to connect to Node.js / TypeScript ```typescript -import { CopilotClient } from "@github/copilot-sdk"; +import { CopilotClient, approveAll } from "@github/copilot-sdk"; const client = new CopilotClient({ cliUrl: "localhost:4321" }); // Use the client normally -const session = await client.createSession(); +const session = await client.createSession({ onPermissionRequest: approveAll }); // ... ``` @@ -1210,7 +1298,7 @@ const session = await client.createSession(); Python ```python -from copilot import CopilotClient +from copilot import CopilotClient, PermissionHandler client = CopilotClient({ "cli_url": "localhost:4321" @@ -1218,7 +1306,7 @@ client = CopilotClient({ await client.start() # Use the client normally -session = await client.create_session() +session = await client.create_session({"on_permission_request": PermissionHandler.approve_all}) # ... ``` @@ -1227,7 +1315,37 @@ session = await client.create_session()
Go - + +```go +package main + +import ( + "context" + "log" + + copilot "github.com/github/copilot-sdk/go" +) + +func main() { + ctx := context.Background() + + client := copilot.NewClient(&copilot.ClientOptions{ + CLIUrl: "localhost:4321", + }) + + if err := client.Start(ctx); err != nil { + log.Fatal(err) + } + defer client.Stop() + + // Use the client normally + _, _ = client.CreateSession(ctx, &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) +} +``` + + ```go import copilot "github.com/github/copilot-sdk/go" @@ -1241,7 +1359,9 @@ if err := client.Start(ctx); err != nil { defer client.Stop() // Use the client normally -session, err := client.CreateSession(ctx, nil) +session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, +}) // ... ``` @@ -1260,7 +1380,10 @@ using var client = new CopilotClient(new CopilotClientOptions }); // Use the client normally -await using var session = await client.CreateSessionAsync(); +await using var session = await client.CreateSessionAsync(new() +{ + OnPermissionRequest = PermissionHandler.ApproveAll +}); // ... ``` diff --git a/docs/guides/custom-agents.md b/docs/guides/custom-agents.md new file mode 100644 index 000000000..16f7a37a0 --- /dev/null +++ b/docs/guides/custom-agents.md @@ -0,0 +1,533 @@ +# Custom Agents & Sub-Agent Orchestration + +Define specialized agents with scoped tools and prompts, then let Copilot orchestrate them as sub-agents within a single session. + +## Overview + +Custom agents are lightweight agent definitions you attach to a session. Each agent has its own system prompt, tool restrictions, and optional MCP servers. When a user's request matches an agent's expertise, the Copilot runtime automatically delegates to that agent as a **sub-agent** — running it in an isolated context while streaming lifecycle events back to the parent session. + +```mermaid +flowchart TD + U[User prompt] --> P[Parent agent] + P -->|delegates| S1[🔍 researcher sub-agent] + P -->|delegates| S2[✏️ editor sub-agent] + S1 -->|subagent.completed| P + S2 -->|subagent.completed| P + P --> R[Final response] +``` + +| Concept | Description | +|---------|-------------| +| **Custom agent** | A named agent config with its own prompt and tool set | +| **Sub-agent** | A custom agent invoked by the runtime to handle part of a task | +| **Inference** | The runtime's ability to auto-select an agent based on the user's intent | +| **Parent session** | The session that spawned the sub-agent; receives all lifecycle events | + +## Defining Custom Agents + +Pass `customAgents` when creating a session. Each agent needs at minimum a `name` and `prompt`. + +
+Node.js / TypeScript + +```typescript +import { CopilotClient } from "@github/copilot-sdk"; + +const client = new CopilotClient(); +await client.start(); + +const session = await client.createSession({ + model: "gpt-4.1", + customAgents: [ + { + name: "researcher", + displayName: "Research Agent", + description: "Explores codebases and answers questions using read-only tools", + tools: ["grep", "glob", "view"], + prompt: "You are a research assistant. Analyze code and answer questions. Do not modify any files.", + }, + { + name: "editor", + displayName: "Editor Agent", + description: "Makes targeted code changes", + tools: ["view", "edit", "bash"], + prompt: "You are a code editor. Make minimal, surgical changes to files as requested.", + }, + ], + onPermissionRequest: async () => ({ kind: "approved" }), +}); +``` + +
+ +
+Python + +```python +from copilot import CopilotClient + +client = CopilotClient() +await client.start() + +session = await client.create_session({ + "model": "gpt-4.1", + "custom_agents": [ + { + "name": "researcher", + "display_name": "Research Agent", + "description": "Explores codebases and answers questions using read-only tools", + "tools": ["grep", "glob", "view"], + "prompt": "You are a research assistant. Analyze code and answer questions. Do not modify any files.", + }, + { + "name": "editor", + "display_name": "Editor Agent", + "description": "Makes targeted code changes", + "tools": ["view", "edit", "bash"], + "prompt": "You are a code editor. Make minimal, surgical changes to files as requested.", + }, + ], + "on_permission_request": lambda req: {"kind": "approved"}, +}) +``` + +
+ +
+Go + + +```go +ctx := context.Background() +client := copilot.NewClient(nil) +client.Start(ctx) + +session, _ := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "gpt-4.1", + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "researcher", + DisplayName: "Research Agent", + Description: "Explores codebases and answers questions using read-only tools", + Tools: []string{"grep", "glob", "view"}, + Prompt: "You are a research assistant. Analyze code and answer questions. Do not modify any files.", + }, + { + Name: "editor", + DisplayName: "Editor Agent", + Description: "Makes targeted code changes", + Tools: []string{"view", "edit", "bash"}, + Prompt: "You are a code editor. Make minimal, surgical changes to files as requested.", + }, + }, + OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil + }, +}) +``` + +
+ +
+.NET + +```csharp +using GitHub.Copilot.SDK; + +await using var client = new CopilotClient(); +await using var session = await client.CreateSessionAsync(new SessionConfig +{ + Model = "gpt-4.1", + CustomAgents = new List + { + new() + { + Name = "researcher", + DisplayName = "Research Agent", + Description = "Explores codebases and answers questions using read-only tools", + Tools = new List { "grep", "glob", "view" }, + Prompt = "You are a research assistant. Analyze code and answer questions. Do not modify any files.", + }, + new() + { + Name = "editor", + DisplayName = "Editor Agent", + Description = "Makes targeted code changes", + Tools = new List { "view", "edit", "bash" }, + Prompt = "You are a code editor. Make minimal, surgical changes to files as requested.", + }, + }, + OnPermissionRequest = (req, inv) => + Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }), +}); +``` + +
+ +## Configuration Reference + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `name` | `string` | ✅ | Unique identifier for the agent | +| `displayName` | `string` | | Human-readable name shown in events | +| `description` | `string` | | What the agent does — helps the runtime select it | +| `tools` | `string[]` or `null` | | Tool names the agent can use. `null` or omitted = all tools | +| `prompt` | `string` | ✅ | System prompt for the agent | +| `mcpServers` | `object` | | MCP server configurations specific to this agent | +| `infer` | `boolean` | | Whether the runtime can auto-select this agent (default: `true`) | + +> **Tip:** A good `description` helps the runtime match user intent to the right agent. Be specific about the agent's expertise and capabilities. + +## How Sub-Agent Delegation Works + +When you send a prompt to a session with custom agents, the runtime evaluates whether to delegate to a sub-agent: + +1. **Intent matching** — The runtime analyzes the user's prompt against each agent's `name` and `description` +2. **Agent selection** — If a match is found and `infer` is not `false`, the runtime selects the agent +3. **Isolated execution** — The sub-agent runs with its own prompt and restricted tool set +4. **Event streaming** — Lifecycle events (`subagent.started`, `subagent.completed`, etc.) stream back to the parent session +5. **Result integration** — The sub-agent's output is incorporated into the parent agent's response + +### Controlling Inference + +By default, all custom agents are available for automatic selection (`infer: true`). Set `infer: false` to prevent the runtime from auto-selecting an agent — useful for agents you only want invoked through explicit user requests: + +```typescript +{ + name: "dangerous-cleanup", + description: "Deletes unused files and dead code", + tools: ["bash", "edit", "view"], + prompt: "You clean up codebases by removing dead code and unused files.", + infer: false, // Only invoked when user explicitly asks for this agent +} +``` + +## Listening to Sub-Agent Events + +When a sub-agent runs, the parent session emits lifecycle events. Subscribe to these events to build UIs that visualize agent activity. + +### Event Types + +| Event | Emitted when | Data | +|-------|-------------|------| +| `subagent.selected` | Runtime selects an agent for the task | `agentName`, `agentDisplayName`, `tools` | +| `subagent.started` | Sub-agent begins execution | `toolCallId`, `agentName`, `agentDisplayName`, `agentDescription` | +| `subagent.completed` | Sub-agent finishes successfully | `toolCallId`, `agentName`, `agentDisplayName` | +| `subagent.failed` | Sub-agent encounters an error | `toolCallId`, `agentName`, `agentDisplayName`, `error` | +| `subagent.deselected` | Runtime switches away from the sub-agent | — | + +### Subscribing to Events + +
+Node.js / TypeScript + +```typescript +session.on((event) => { + switch (event.type) { + case "subagent.started": + console.log(`▶ Sub-agent started: ${event.data.agentDisplayName}`); + console.log(` Description: ${event.data.agentDescription}`); + console.log(` Tool call ID: ${event.data.toolCallId}`); + break; + + case "subagent.completed": + console.log(`✅ Sub-agent completed: ${event.data.agentDisplayName}`); + break; + + case "subagent.failed": + console.log(`❌ Sub-agent failed: ${event.data.agentDisplayName}`); + console.log(` Error: ${event.data.error}`); + break; + + case "subagent.selected": + console.log(`🎯 Agent selected: ${event.data.agentDisplayName}`); + console.log(` Tools: ${event.data.tools?.join(", ") ?? "all"}`); + break; + + case "subagent.deselected": + console.log("↩ Agent deselected, returning to parent"); + break; + } +}); + +const response = await session.sendAndWait({ + prompt: "Research how authentication works in this codebase", +}); +``` + +
+ +
+Python + +```python +def handle_event(event): + if event.type == "subagent.started": + print(f"▶ Sub-agent started: {event.data.agent_display_name}") + print(f" Description: {event.data.agent_description}") + elif event.type == "subagent.completed": + print(f"✅ Sub-agent completed: {event.data.agent_display_name}") + elif event.type == "subagent.failed": + print(f"❌ Sub-agent failed: {event.data.agent_display_name}") + print(f" Error: {event.data.error}") + elif event.type == "subagent.selected": + tools = event.data.tools or "all" + print(f"🎯 Agent selected: {event.data.agent_display_name} (tools: {tools})") + +unsubscribe = session.on(handle_event) + +response = await session.send_and_wait({ + "prompt": "Research how authentication works in this codebase" +}) +``` + +
+ +
+Go + + +```go +session.On(func(event copilot.SessionEvent) { + switch event.Type { + case "subagent.started": + fmt.Printf("▶ Sub-agent started: %s\n", *event.Data.AgentDisplayName) + fmt.Printf(" Description: %s\n", *event.Data.AgentDescription) + fmt.Printf(" Tool call ID: %s\n", *event.Data.ToolCallID) + case "subagent.completed": + fmt.Printf("✅ Sub-agent completed: %s\n", *event.Data.AgentDisplayName) + case "subagent.failed": + fmt.Printf("❌ Sub-agent failed: %s — %v\n", *event.Data.AgentDisplayName, event.Data.Error) + case "subagent.selected": + fmt.Printf("🎯 Agent selected: %s\n", *event.Data.AgentDisplayName) + } +}) + +_, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "Research how authentication works in this codebase", +}) +``` + +
+ +
+.NET + + +```csharp +using GitHub.Copilot.SDK; + +public static class SubAgentEventsExample +{ + public static async Task Example(CopilotSession session) + { + using var subscription = session.On(evt => + { + switch (evt) + { + case SubagentStartedEvent started: + Console.WriteLine($"▶ Sub-agent started: {started.Data.AgentDisplayName}"); + Console.WriteLine($" Description: {started.Data.AgentDescription}"); + Console.WriteLine($" Tool call ID: {started.Data.ToolCallId}"); + break; + case SubagentCompletedEvent completed: + Console.WriteLine($"✅ Sub-agent completed: {completed.Data.AgentDisplayName}"); + break; + case SubagentFailedEvent failed: + Console.WriteLine($"❌ Sub-agent failed: {failed.Data.AgentDisplayName} — {failed.Data.Error}"); + break; + case SubagentSelectedEvent selected: + Console.WriteLine($"🎯 Agent selected: {selected.Data.AgentDisplayName}"); + break; + } + }); + + await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Research how authentication works in this codebase" + }); + } +} +``` + + +```csharp +using var subscription = session.On(evt => +{ + switch (evt) + { + case SubagentStartedEvent started: + Console.WriteLine($"▶ Sub-agent started: {started.Data.AgentDisplayName}"); + Console.WriteLine($" Description: {started.Data.AgentDescription}"); + Console.WriteLine($" Tool call ID: {started.Data.ToolCallId}"); + break; + case SubagentCompletedEvent completed: + Console.WriteLine($"✅ Sub-agent completed: {completed.Data.AgentDisplayName}"); + break; + case SubagentFailedEvent failed: + Console.WriteLine($"❌ Sub-agent failed: {failed.Data.AgentDisplayName} — {failed.Data.Error}"); + break; + case SubagentSelectedEvent selected: + Console.WriteLine($"🎯 Agent selected: {selected.Data.AgentDisplayName}"); + break; + } +}); + +await session.SendAndWaitAsync(new MessageOptions +{ + Prompt = "Research how authentication works in this codebase" +}); +``` + +
+ +## Building an Agent Tree UI + +Sub-agent events include `toolCallId` fields that let you reconstruct the execution tree. Here's a pattern for tracking agent activity: + +```typescript +interface AgentNode { + toolCallId: string; + name: string; + displayName: string; + status: "running" | "completed" | "failed"; + error?: string; + startedAt: Date; + completedAt?: Date; +} + +const agentTree = new Map(); + +session.on((event) => { + if (event.type === "subagent.started") { + agentTree.set(event.data.toolCallId, { + toolCallId: event.data.toolCallId, + name: event.data.agentName, + displayName: event.data.agentDisplayName, + status: "running", + startedAt: new Date(event.timestamp), + }); + } + + if (event.type === "subagent.completed") { + const node = agentTree.get(event.data.toolCallId); + if (node) { + node.status = "completed"; + node.completedAt = new Date(event.timestamp); + } + } + + if (event.type === "subagent.failed") { + const node = agentTree.get(event.data.toolCallId); + if (node) { + node.status = "failed"; + node.error = event.data.error; + node.completedAt = new Date(event.timestamp); + } + } + + // Render your UI with the updated tree + renderAgentTree(agentTree); +}); +``` + +## Scoping Tools per Agent + +Use the `tools` property to restrict which tools an agent can access. This is essential for security and for keeping agents focused: + +```typescript +const session = await client.createSession({ + customAgents: [ + { + name: "reader", + description: "Read-only exploration of the codebase", + tools: ["grep", "glob", "view"], // No write access + prompt: "You explore and analyze code. Never suggest modifications directly.", + }, + { + name: "writer", + description: "Makes code changes", + tools: ["view", "edit", "bash"], // Write access + prompt: "You make precise code changes as instructed.", + }, + { + name: "unrestricted", + description: "Full access agent for complex tasks", + tools: null, // All tools available + prompt: "You handle complex multi-step tasks using any available tools.", + }, + ], +}); +``` + +> **Note:** When `tools` is `null` or omitted, the agent inherits access to all tools configured on the session. Use explicit tool lists to enforce the principle of least privilege. + +## Attaching MCP Servers to Agents + +Each custom agent can have its own MCP (Model Context Protocol) servers, giving it access to specialized data sources: + +```typescript +const session = await client.createSession({ + customAgents: [ + { + name: "db-analyst", + description: "Analyzes database schemas and queries", + prompt: "You are a database expert. Use the database MCP server to analyze schemas.", + mcpServers: { + "database": { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"], + }, + }, + }, + ], +}); +``` + +## Patterns & Best Practices + +### Pair a researcher with an editor + +A common pattern is to define a read-only researcher agent and a write-capable editor agent. The runtime delegates exploration tasks to the researcher and modification tasks to the editor: + +```typescript +customAgents: [ + { + name: "researcher", + description: "Analyzes code structure, finds patterns, and answers questions", + tools: ["grep", "glob", "view"], + prompt: "You are a code analyst. Thoroughly explore the codebase to answer questions.", + }, + { + name: "implementer", + description: "Implements code changes based on analysis", + tools: ["view", "edit", "bash"], + prompt: "You make minimal, targeted code changes. Always verify changes compile.", + }, +] +``` + +### Keep agent descriptions specific + +The runtime uses the `description` to match user intent. Vague descriptions lead to poor delegation: + +```typescript +// ❌ Too vague — runtime can't distinguish from other agents +{ description: "Helps with code" } + +// ✅ Specific — runtime knows when to delegate +{ description: "Analyzes Python test coverage and identifies untested code paths" } +``` + +### Handle failures gracefully + +Sub-agents can fail. Always listen for `subagent.failed` events and handle them in your application: + +```typescript +session.on((event) => { + if (event.type === "subagent.failed") { + logger.error(`Agent ${event.data.agentName} failed: ${event.data.error}`); + // Show error in UI, retry, or fall back to parent agent + } +}); +``` diff --git a/docs/guides/session-persistence.md b/docs/guides/session-persistence.md index 527f5ecc7..e2b736c1b 100644 --- a/docs/guides/session-persistence.md +++ b/docs/guides/session-persistence.md @@ -325,24 +325,46 @@ async function cleanupExpiredSessions(maxAgeMs: number) { await cleanupExpiredSessions(24 * 60 * 60 * 1000); ``` -### Explicit Session Destruction +### Disconnecting from a Session (`disconnect`) -When a task completes, destroy the session explicitly rather than waiting for timeouts: +When a task completes, disconnect from the session explicitly rather than waiting for timeouts. This releases in-memory resources but **preserves session data on disk**, so the session can still be resumed later: ```typescript try { // Do work... await session.sendAndWait({ prompt: "Complete the task" }); - // Task complete - clean up - await session.destroy(); + // Task complete — release in-memory resources (session can be resumed later) + await session.disconnect(); } catch (error) { // Clean up even on error - await session.destroy(); + await session.disconnect(); throw error; } ``` +Each SDK also provides idiomatic automatic cleanup patterns: + +| Language | Pattern | Example | +|----------|---------|---------| +| **TypeScript** | `Symbol.asyncDispose` | `await using session = await client.createSession(config);` | +| **Python** | `async with` context manager | `async with await client.create_session(config) as session:` | +| **C#** | `IAsyncDisposable` | `await using var session = await client.CreateSessionAsync(config);` | +| **Go** | `defer` | `defer session.Disconnect()` | + +> **Note:** `destroy()` is deprecated in favor of `disconnect()`. Existing code using `destroy()` will continue to work but should be migrated. + +### Permanently Deleting a Session (`deleteSession`) + +To permanently remove a session and all its data from disk (conversation history, planning state, artifacts), use `deleteSession`. This is irreversible — the session **cannot** be resumed after deletion: + +```typescript +// Permanently remove session data +await client.deleteSession("user-123-task-456"); +``` + +> **`disconnect()` vs `deleteSession()`:** `disconnect()` releases in-memory resources but keeps session data on disk for later resumption. `deleteSession()` permanently removes everything, including files on disk. + ## Automatic Cleanup: Idle Timeout The CLI has a built-in 30-minute idle timeout. Sessions without activity are automatically cleaned up: @@ -526,8 +548,8 @@ await withSessionLock("user-123-task-456", async () => { | **Resume session** | `client.resumeSession(sessionId)` | | **BYOK resume** | Re-provide `provider` config | | **List sessions** | `client.listSessions(filter?)` | -| **Delete session** | `client.deleteSession(sessionId)` | -| **Destroy active session** | `session.destroy()` | +| **Disconnect from active session** | `session.disconnect()` — releases in-memory resources; session data on disk is preserved for resumption | +| **Delete session permanently** | `client.deleteSession(sessionId)` — permanently removes all session data from disk; cannot be resumed | | **Containerized deployment** | Mount `~/.copilot/session-state/` to persistent storage | ## Next Steps diff --git a/docs/guides/setup/azure-managed-identity.md b/docs/guides/setup/azure-managed-identity.md index bfafc6f91..9ad1ddb15 100644 --- a/docs/guides/setup/azure-managed-identity.md +++ b/docs/guides/setup/azure-managed-identity.md @@ -118,7 +118,7 @@ class ManagedIdentityCopilotAgent: session = await self.client.create_session(config) response = await session.send_and_wait({"prompt": prompt}) - await session.destroy() + await session.disconnect() return response.data.content if response else "" ``` diff --git a/docs/guides/setup/backend-services.md b/docs/guides/setup/backend-services.md index c9bc13f8d..e0d0975db 100644 --- a/docs/guides/setup/backend-services.md +++ b/docs/guides/setup/backend-services.md @@ -319,7 +319,7 @@ async function processJob(job: Job) { }); await saveResult(job.id, response?.data.content); - await session.destroy(); // Clean up after job completes + await session.disconnect(); // Clean up after job completes } ``` diff --git a/docs/guides/setup/scaling.md b/docs/guides/setup/scaling.md index fcdb716da..974276e5e 100644 --- a/docs/guides/setup/scaling.md +++ b/docs/guides/setup/scaling.md @@ -412,8 +412,8 @@ class SessionManager { private async evictOldestSession(): Promise { const [oldestId] = this.activeSessions.keys(); const session = this.activeSessions.get(oldestId)!; - // Session state is persisted automatically — safe to destroy - await session.destroy(); + // Session state is persisted automatically — safe to disconnect + await session.disconnect(); this.activeSessions.delete(oldestId); } } @@ -457,7 +457,7 @@ app.post("/api/analyze", async (req, res) => { }); res.json({ result: response?.data.content }); } finally { - await session.destroy(); // Clean up immediately + await session.disconnect(); // Clean up immediately } }); ``` diff --git a/docs/guides/skills.md b/docs/guides/skills.md index e17a62093..5e085e3b2 100644 --- a/docs/guides/skills.md +++ b/docs/guides/skills.md @@ -1,13 +1,10 @@ # Custom Skills -Skills are reusable collections of prompts, tools, and configuration that extend Copilot's capabilities. Load skills from directories to give Copilot specialized abilities for specific domains or workflows. +Skills are reusable prompt modules that extend Copilot's capabilities. Load skills from directories to give Copilot specialized abilities for specific domains or workflows. ## Overview -A skill is a directory containing: -- **Prompt files** - Instructions that guide Copilot's behavior -- **Tool definitions** - Custom tools the skill provides -- **Configuration** - Metadata about the skill +A skill is a named directory containing a `SKILL.md` file — a markdown document that provides instructions to Copilot. When loaded, the skill's content is injected into the session context. Skills allow you to: - Package domain expertise into reusable modules @@ -31,8 +28,8 @@ const session = await client.createSession({ skillDirectories: [ "./skills/code-review", "./skills/documentation", - "~/.copilot/skills", // User-level skills ], + onPermissionRequest: async () => ({ kind: "approved" }), }); // Copilot now has access to skills in those directories @@ -56,8 +53,8 @@ async def main(): "skill_directories": [ "./skills/code-review", "./skills/documentation", - "~/.copilot/skills", # User-level skills ], + "on_permission_request": lambda req: {"kind": "approved"}, }) # Copilot now has access to skills in those directories @@ -93,7 +90,9 @@ func main() { SkillDirectories: []string{ "./skills/code-review", "./skills/documentation", - "~/.copilot/skills", // User-level skills + }, + OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil }, }) if err != nil { @@ -126,8 +125,9 @@ await using var session = await client.CreateSessionAsync(new SessionConfig { "./skills/code-review", "./skills/documentation", - "~/.copilot/skills", // User-level skills }, + OnPermissionRequest = (req, inv) => + Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }), }); // Copilot now has access to skills in those directories @@ -196,41 +196,28 @@ var session = await client.CreateSessionAsync(new SessionConfig ## Skill Directory Structure -A typical skill directory contains: +Each skill is a named subdirectory containing a `SKILL.md` file: ``` skills/ -└── code-review/ - ├── skill.json # Skill metadata and configuration - ├── prompts/ - │ ├── system.md # System prompt additions - │ └── examples.md # Few-shot examples - └── tools/ - └── lint.json # Tool definitions +├── code-review/ +│ └── SKILL.md +└── documentation/ + └── SKILL.md ``` -### skill.json - -The skill manifest file: +The `skillDirectories` option points to the parent directory (e.g., `./skills`). The CLI discovers all `SKILL.md` files in immediate subdirectories. -```json -{ - "name": "code-review", - "displayName": "Code Review Assistant", - "description": "Specialized code review capabilities", - "version": "1.0.0", - "author": "Your Team", - "prompts": ["prompts/system.md"], - "tools": ["tools/lint.json"] -} -``` +### SKILL.md Format -### Prompt Files - -Markdown files that provide context to Copilot: +A `SKILL.md` file is a markdown document with optional YAML frontmatter: ```markdown - +--- +name: code-review +description: Specialized code review capabilities +--- + # Code Review Guidelines When reviewing code, always check for: @@ -243,6 +230,12 @@ When reviewing code, always check for: Provide specific line-number references and suggested fixes. ``` +The frontmatter fields: +- **`name`** — The skill's identifier (used with `disabledSkills` to selectively disable it). If omitted, the directory name is used. +- **`description`** — A short description of what the skill does. + +The markdown body contains the instructions that are injected into the session context when the skill is loaded. + ## Configuration Options ### SessionConfig Skill Fields @@ -262,7 +255,7 @@ Provide specific line-number references and suggested fixes. 1. **Organize by domain** - Group related skills together (e.g., `skills/security/`, `skills/testing/`) -2. **Version your skills** - Include version numbers in `skill.json` for compatibility tracking +2. **Use frontmatter** - Include `name` and `description` in YAML frontmatter for clarity 3. **Document dependencies** - Note any tools or MCP servers a skill requires @@ -284,6 +277,7 @@ const session = await client.createSession({ description: "Security-focused code reviewer", prompt: "Focus on OWASP Top 10 vulnerabilities", }], + onPermissionRequest: async () => ({ kind: "approved" }), }); ``` @@ -302,6 +296,7 @@ const session = await client.createSession({ tools: ["*"], }, }, + onPermissionRequest: async () => ({ kind: "approved" }), }); ``` @@ -309,16 +304,16 @@ const session = await client.createSession({ ### Skills Not Loading -1. **Check path exists** - Verify the directory path is correct +1. **Check path exists** - Verify the skill directory path is correct and contains subdirectories with `SKILL.md` files 2. **Check permissions** - Ensure the SDK can read the directory -3. **Validate skill.json** - Check for JSON syntax errors +3. **Check SKILL.md format** - Verify the markdown is well-formed and any YAML frontmatter uses valid syntax 4. **Enable debug logging** - Set `logLevel: "debug"` to see skill loading logs ### Skill Conflicts -If multiple skills define the same tool: -- Later directories in the array take precedence +If multiple skills provide conflicting instructions: - Use `disabledSkills` to exclude conflicting skills +- Reorganize skill directories to avoid overlaps ## See Also diff --git a/docs/mcp/overview.md b/docs/mcp/overview.md index aa2fba668..5ad8b1df3 100644 --- a/docs/mcp/overview.md +++ b/docs/mcp/overview.md @@ -132,7 +132,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() // Use the session... } @@ -191,7 +191,7 @@ async function main() { console.log("Response:", result?.data?.content); - await session.destroy(); + await session.disconnect(); await client.stop(); } diff --git a/docs/opentelemetry-instrumentation.md b/docs/opentelemetry-instrumentation.md new file mode 100644 index 000000000..f0e1b2556 --- /dev/null +++ b/docs/opentelemetry-instrumentation.md @@ -0,0 +1,570 @@ +# OpenTelemetry Instrumentation for Copilot SDK + +This guide shows how to add OpenTelemetry tracing to your Copilot SDK applications using GenAI semantic conventions. + +## Overview + +The Copilot SDK emits session events as your agent processes requests. You can instrument your application to convert these events into OpenTelemetry spans and attributes following the [OpenTelemetry GenAI Semantic Conventions v1.34.0](https://opentelemetry.io/docs/specs/semconv/gen-ai/). + +## Installation + +```bash +pip install opentelemetry-sdk opentelemetry-api +``` + +For exporting to observability backends: + +```bash +# Console output +pip install opentelemetry-sdk + +# Azure Monitor +pip install azure-monitor-opentelemetry + +# OTLP (Jaeger, Prometheus, etc.) +pip install opentelemetry-exporter-otlp +``` + +## Basic Setup + +### 1. Initialize OpenTelemetry + +```python +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter + +# Setup tracer provider +tracer_provider = TracerProvider() +trace.set_tracer_provider(tracer_provider) + +# Add exporter (console example) +span_exporter = ConsoleSpanExporter() +tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + +# Get a tracer +tracer = trace.get_tracer(__name__) +``` + +### 2. Create Spans Around Agent Operations + +```python +from copilot import CopilotClient, PermissionHandler +from copilot.generated.session_events import SessionEventType +from opentelemetry import trace, context +from opentelemetry.trace import SpanKind + +# Initialize client and start the CLI server +client = CopilotClient() +await client.start() + +tracer = trace.get_tracer(__name__) + +# Create a span for the agent invocation +span_attrs = { + "gen_ai.operation.name": "invoke_agent", + "gen_ai.provider.name": "github.copilot", + "gen_ai.agent.name": "my-agent", + "gen_ai.request.model": "gpt-5", +} + +span = tracer.start_span( + name="invoke_agent my-agent", + kind=SpanKind.CLIENT, + attributes=span_attrs +) +token = context.attach(trace.set_span_in_context(span)) + +try: + # Create a session (model is set here, not on the client) + session = await client.create_session({ + "model": "gpt-5", + "on_permission_request": PermissionHandler.approve_all, + }) + + # Subscribe to events via callback + def handle_event(event): + if event.type == SessionEventType.ASSISTANT_USAGE: + if event.data.model: + span.set_attribute("gen_ai.response.model", event.data.model) + + unsubscribe = session.on(handle_event) + + # Send a message (returns a message ID) + await session.send({"prompt": "Hello, world!"}) + + # Or send and wait for the session to become idle + response = await session.send_and_wait({"prompt": "Hello, world!"}) +finally: + context.detach(token) + span.end() + await client.stop() +``` + +## Copilot SDK Event to GenAI Attribute Mapping + +The Copilot SDK emits `SessionEventType` events during agent execution. Subscribe to these events using `session.on(handler)`, which returns an unsubscribe function. Here's how to map these events to GenAI semantic convention attributes: + +### Core Session Events + +| SessionEventType | GenAI Attributes | Description | +|------------------|------------------|-------------| +| `SESSION_START` | - | Session initialization (mark span start) | +| `SESSION_IDLE` | - | Session completed (mark span end) | +| `SESSION_ERROR` | `error.type`, `error.message` | Error occurred | + +### Assistant Events + +| SessionEventType | GenAI Attributes | Description | +|------------------|------------------|-------------| +| `ASSISTANT_TURN_START` | - | Assistant begins processing | +| `ASSISTANT_TURN_END` | - | Assistant finished processing | +| `ASSISTANT_MESSAGE` | `gen_ai.output.messages` (event) | Final assistant message with complete content | +| `ASSISTANT_MESSAGE_DELTA` | - | Streaming message chunk (optional to trace) | +| `ASSISTANT_USAGE` | `gen_ai.usage.input_tokens`
`gen_ai.usage.output_tokens`
`gen_ai.response.model` | Token usage and model information | +| `ASSISTANT_REASONING` | - | Reasoning content (optional to trace) | +| `ASSISTANT_INTENT` | - | Assistant's understood intent | + +### Tool Execution Events + +| SessionEventType | GenAI Attributes / Span | Description | +|------------------|-------------------------|-------------| +| `TOOL_EXECUTION_START` | Create child span:
- `gen_ai.tool.name`
- `gen_ai.tool.call.id`
- `gen_ai.operation.name`: `execute_tool`
- `gen_ai.tool.call.arguments` (opt-in) | Tool execution begins | +| `TOOL_EXECUTION_COMPLETE` | On child span:
- `gen_ai.tool.call.result` (opt-in)
- `error.type` (if failed)
End child span | Tool execution finished | +| `TOOL_EXECUTION_PARTIAL_RESULT` | - | Streaming tool result | + +### Model and Context Events + +| SessionEventType | GenAI Attributes | Description | +|------------------|------------------|-------------| +| `SESSION_MODEL_CHANGE` | `gen_ai.request.model` | Model changed during session | +| `SESSION_CONTEXT_CHANGED` | - | Context window modified | +| `SESSION_TRUNCATION` | - | Context truncated | + +## Detailed Event Mapping Examples + +### ASSISTANT_USAGE Event + +When you receive an `ASSISTANT_USAGE` event, extract token usage: + +```python +from copilot.generated.session_events import SessionEventType + +def handle_usage(event): + if event.type == SessionEventType.ASSISTANT_USAGE: + data = event.data + if data.model: + span.set_attribute("gen_ai.response.model", data.model) + if data.input_tokens is not None: + span.set_attribute("gen_ai.usage.input_tokens", int(data.input_tokens)) + if data.output_tokens is not None: + span.set_attribute("gen_ai.usage.output_tokens", int(data.output_tokens)) + +unsubscribe = session.on(handle_usage) +await session.send({"prompt": "Hello"}) +``` + +**Event Data Structure:** + +```python +@dataclass +class Usage: + input_tokens: float + output_tokens: float + cache_read_tokens: float + cache_write_tokens: float +``` + +**Maps to GenAI Attributes:** +- `input_tokens` → `gen_ai.usage.input_tokens` +- `output_tokens` → `gen_ai.usage.output_tokens` +- Response model → `gen_ai.response.model` + +### TOOL_EXECUTION_START / COMPLETE Events + +Create child spans for each tool execution: + +```python +from opentelemetry.trace import SpanKind +import json + +# Dictionary to track active tool spans +tool_spans = {} + +def handle_tool_events(event): + data = event.data + + if event.type == SessionEventType.TOOL_EXECUTION_START and data: + call_id = data.tool_call_id or str(uuid.uuid4()) + tool_name = data.tool_name or "unknown" + + tool_attrs = { + "gen_ai.tool.name": tool_name, + "gen_ai.operation.name": "execute_tool", + } + + if call_id: + tool_attrs["gen_ai.tool.call.id"] = call_id + + # Optional: include tool arguments (may contain sensitive data) + if data.arguments is not None: + try: + tool_attrs["gen_ai.tool.call.arguments"] = json.dumps(data.arguments) + except Exception: + tool_attrs["gen_ai.tool.call.arguments"] = str(data.arguments) + + tool_span = tracer.start_span( + name=f"execute_tool {tool_name}", + kind=SpanKind.CLIENT, + attributes=tool_attrs + ) + tool_token = context.attach(trace.set_span_in_context(tool_span)) + tool_spans[call_id] = (tool_span, tool_token) + + elif event.type == SessionEventType.TOOL_EXECUTION_COMPLETE and data: + call_id = data.tool_call_id + entry = tool_spans.pop(call_id, None) if call_id else None + + if entry: + tool_span, tool_token = entry + + # Optional: include tool result (may contain sensitive data) + if data.result is not None: + try: + result_str = json.dumps(data.result) + except Exception: + result_str = str(data.result) + # Truncate to 512 chars to avoid huge spans + tool_span.set_attribute("gen_ai.tool.call.result", result_str[:512]) + + # Mark as error if tool failed + if hasattr(data, "success") and data.success is False: + tool_span.set_attribute("error.type", "tool_error") + + context.detach(tool_token) + tool_span.end() + +unsubscribe = session.on(handle_tool_events) +await session.send({"prompt": "What's the weather?"}) +``` + +**Tool Event Data:** +- `tool_call_id` → `gen_ai.tool.call.id` +- `tool_name` → `gen_ai.tool.name` +- `arguments` → `gen_ai.tool.call.arguments` (opt-in) +- `result` → `gen_ai.tool.call.result` (opt-in) + +### ASSISTANT_MESSAGE Event + +Capture the final message as a span event: + +```python +def handle_message(event): + if event.type == SessionEventType.ASSISTANT_MESSAGE and event.data: + if event.data.content: + # Add as a span event (opt-in for content recording) + span.add_event( + "gen_ai.output.messages", + attributes={ + "gen_ai.event.content": json.dumps({ + "role": "assistant", + "content": event.data.content + }) + } + ) + +unsubscribe = session.on(handle_message) +await session.send({"prompt": "Tell me a joke"}) +``` + +## Complete Example + +```python +import asyncio +import json +import uuid +from copilot import CopilotClient, PermissionHandler +from copilot.generated.session_events import SessionEventType +from opentelemetry import trace, context +from opentelemetry.trace import SpanKind +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter + +# Setup OpenTelemetry +tracer_provider = TracerProvider() +trace.set_tracer_provider(tracer_provider) +tracer_provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) +tracer = trace.get_tracer(__name__) + +async def invoke_agent(prompt: str): + """Invoke agent with full OpenTelemetry instrumentation.""" + + # Create main span + span_attrs = { + "gen_ai.operation.name": "invoke_agent", + "gen_ai.provider.name": "github.copilot", + "gen_ai.agent.name": "example-agent", + "gen_ai.request.model": "gpt-5", + } + + span = tracer.start_span( + name="invoke_agent example-agent", + kind=SpanKind.CLIENT, + attributes=span_attrs + ) + token = context.attach(trace.set_span_in_context(span)) + tool_spans = {} + + try: + client = CopilotClient() + await client.start() + + session = await client.create_session({ + "model": "gpt-5", + "on_permission_request": PermissionHandler.approve_all, + }) + + # Subscribe to events via callback + def handle_event(event): + data = event.data + + # Handle usage events + if event.type == SessionEventType.ASSISTANT_USAGE and data: + if data.model: + span.set_attribute("gen_ai.response.model", data.model) + if data.input_tokens is not None: + span.set_attribute("gen_ai.usage.input_tokens", int(data.input_tokens)) + if data.output_tokens is not None: + span.set_attribute("gen_ai.usage.output_tokens", int(data.output_tokens)) + + # Handle tool execution + elif event.type == SessionEventType.TOOL_EXECUTION_START and data: + call_id = data.tool_call_id or str(uuid.uuid4()) + tool_name = data.tool_name or "unknown" + + tool_attrs = { + "gen_ai.tool.name": tool_name, + "gen_ai.operation.name": "execute_tool", + "gen_ai.tool.call.id": call_id, + } + + tool_span = tracer.start_span( + name=f"execute_tool {tool_name}", + kind=SpanKind.CLIENT, + attributes=tool_attrs + ) + tool_token = context.attach(trace.set_span_in_context(tool_span)) + tool_spans[call_id] = (tool_span, tool_token) + + elif event.type == SessionEventType.TOOL_EXECUTION_COMPLETE and data: + call_id = data.tool_call_id + entry = tool_spans.pop(call_id, None) if call_id else None + if entry: + tool_span, tool_token = entry + context.detach(tool_token) + tool_span.end() + + # Capture final message + elif event.type == SessionEventType.ASSISTANT_MESSAGE and data: + if data.content: + print(f"Assistant: {data.content}") + + unsubscribe = session.on(handle_event) + + # Send message and wait for completion + response = await session.send_and_wait({"prompt": prompt}) + + span.set_attribute("gen_ai.response.finish_reasons", ["stop"]) + unsubscribe() + + except Exception as e: + span.set_attribute("error.type", type(e).__name__) + raise + finally: + # Clean up any unclosed tool spans + for call_id, (tool_span, tool_token) in tool_spans.items(): + tool_span.set_attribute("error.type", "stream_aborted") + context.detach(tool_token) + tool_span.end() + + context.detach(token) + span.end() + await client.stop() + +# Run +asyncio.run(invoke_agent("What's 2+2?")) +``` + +## Required Span Attributes + +According to OpenTelemetry GenAI semantic conventions, these attributes are **required** for agent invocation spans: + +| Attribute | Description | Example | +|-----------|-------------|---------| +| `gen_ai.operation.name` | Operation type | `invoke_agent`, `chat`, `execute_tool` | +| `gen_ai.provider.name` | Provider identifier | `github.copilot` | +| `gen_ai.request.model` | Model used for request | `gpt-5`, `gpt-4.1` | + +## Recommended Span Attributes + +These attributes are **recommended** for better observability: + +| Attribute | Description | +|-----------|-------------| +| `gen_ai.agent.id` | Unique agent identifier | +| `gen_ai.agent.name` | Human-readable agent name | +| `gen_ai.response.model` | Actual model used in response | +| `gen_ai.usage.input_tokens` | Input tokens consumed | +| `gen_ai.usage.output_tokens` | Output tokens generated | +| `gen_ai.response.finish_reasons` | Completion reasons (e.g., `["stop"]`) | + +## Content Recording + +Recording message content and tool arguments/results is **optional** and should be opt-in since it may contain sensitive data. + +### Environment Variable Control + +```bash +# Enable content recording +export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true +``` + +### Checking at Runtime + + +```python +import os + +def should_record_content(): + return os.getenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "false").lower() == "true" + +# Only add content if enabled +if should_record_content() and event.data.content: + span.add_event("gen_ai.output.messages", ...) +``` + +## MCP (Model Context Protocol) Tool Conventions + +For MCP-based tools, add these additional attributes following the [OpenTelemetry MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/): + + +```python +tool_attrs = { + # Required + "mcp.method.name": "tools/call", + + # Recommended + "mcp.server.name": data.mcp_server_name, + "mcp.session.id": session.session_id, + + # GenAI attributes + "gen_ai.tool.name": data.mcp_tool_name, + "gen_ai.operation.name": "execute_tool", + "network.transport": "pipe", # Copilot SDK uses stdio +} +``` + +## Span Naming Conventions + +Follow these patterns for span names: + +| Operation | Span Name Pattern | Example | +|-----------|-------------------|---------| +| Agent invocation | `invoke_agent {agent_name}` | `invoke_agent weather-bot` | +| Chat | `chat` | `chat` | +| Tool execution | `execute_tool {tool_name}` | `execute_tool fetch_weather` | +| MCP tool | `tools/call {tool_name}` | `tools/call read_file` | + +## Metrics + +You can also export metrics for token usage and operation duration: + +```python +from opentelemetry import metrics +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader + +# Setup metrics +reader = PeriodicExportingMetricReader(ConsoleMetricExporter()) +provider = MeterProvider(metric_readers=[reader]) +metrics.set_meter_provider(provider) + +meter = metrics.get_meter(__name__) + +# Create metrics +operation_duration = meter.create_histogram( + name="gen_ai.client.operation.duration", + description="Duration of GenAI operations", + unit="ms" +) + +token_usage = meter.create_counter( + name="gen_ai.client.token.usage", + description="Token usage count" +) + +# Record metrics +operation_duration.record(123.45, attributes={ + "gen_ai.operation.name": "invoke_agent", + "gen_ai.request.model": "gpt-5", +}) + +token_usage.add(150, attributes={ + "gen_ai.token.type": "input", + "gen_ai.operation.name": "invoke_agent", +}) +``` + +## Azure Monitor Integration + +For production observability with Azure Monitor: + +```python +from azure.monitor.opentelemetry import configure_azure_monitor + +# Enable Azure Monitor +connection_string = "InstrumentationKey=..." +configure_azure_monitor(connection_string=connection_string) + +# Your instrumented code here +``` + +View traces in the Azure Portal under your Application Insights resource → Tracing. + +## Best Practices + +1. **Always close spans**: Use try/finally blocks to ensure spans are ended even on errors +2. **Set error attributes**: On exceptions, set `error.type` and optionally `error.message` +3. **Use child spans for tools**: Create separate spans for each tool execution +4. **Opt-in for content**: Only record message content and tool arguments when explicitly enabled +5. **Truncate large values**: Limit tool results and arguments to reasonable sizes (e.g., 512 chars) +6. **Set finish reasons**: Always set `gen_ai.response.finish_reasons` when the operation completes successfully +7. **Include model info**: Capture both request and response model names + +## Troubleshooting + +### No spans appearing + +1. Verify tracer provider is set: `trace.set_tracer_provider(provider)` +2. Add a span processor: `provider.add_span_processor(SimpleSpanProcessor(exporter))` +3. Ensure spans are ended: Check for missing `span.end()` calls + +### Tool spans not showing as children + +Make sure to attach the tool span to the parent context: + +```python +tool_token = context.attach(trace.set_span_in_context(tool_span)) +``` + +### Context warnings in async code + +You may see "Failed to detach context" warnings in async streaming code. These are expected and don't affect tracing correctness. + +## References + +- [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) +- [OpenTelemetry MCP Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/) +- [OpenTelemetry Python SDK](https://opentelemetry.io/docs/instrumentation/python/) +- [GenAI Semantic Conventions v1.34.0](https://opentelemetry.io/schemas/1.34.0) +- [Copilot SDK Documentation](https://github.com/github/copilot-sdk) diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props new file mode 100644 index 000000000..badf8483d --- /dev/null +++ b/dotnet/Directory.Build.props @@ -0,0 +1,12 @@ + + + + net8.0 + 14 + enable + enable + 10.0-minimum + true + + + diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props new file mode 100644 index 000000000..5447fee51 --- /dev/null +++ b/dotnet/Directory.Packages.props @@ -0,0 +1,19 @@ + + + + true + + + + + + + + + + + + + + + diff --git a/dotnet/README.md b/dotnet/README.md index bda10059d..bdb3e8dab 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -77,8 +77,8 @@ new CopilotClient(CopilotClientOptions? options = null) - `Cwd` - Working directory for the CLI process - `Environment` - Environment variables to pass to the CLI process - `Logger` - `ILogger` instance for SDK logging -- `GithubToken` - GitHub token for authentication. When provided, takes priority over other auth methods. -- `UseLoggedInUser` - Whether to use logged-in user for authentication (default: true, but false when `GithubToken` is provided). Cannot be used with `CliUrl`. +- `GitHubToken` - GitHub token for authentication. When provided, takes priority over other auth methods. +- `UseLoggedInUser` - Whether to use logged-in user for authentication (default: true, but false when `GitHubToken` is provided). Cannot be used with `CliUrl`. #### Methods @@ -219,7 +219,17 @@ Get all events/messages from this session. ##### `DisposeAsync(): ValueTask` -Dispose the session and free resources. +Close the session and release in-memory resources. Session data on disk is preserved — the conversation can be resumed later via `ResumeSessionAsync()`. To permanently delete session data, use `client.DeleteSessionAsync()`. + +```csharp +// Preferred: automatic cleanup via await using +await using var session = await client.CreateSessionAsync(config); +// session is automatically disposed when leaving scope + +// Alternative: explicit dispose +var session2 = await client.CreateSessionAsync(config); +await session2.DisposeAsync(); +``` --- @@ -415,6 +425,30 @@ var session = await client.CreateSessionAsync(new SessionConfig When Copilot invokes `lookup_issue`, the client automatically runs your handler and responds to the CLI. Handlers can return any JSON-serializable value (automatically wrapped), or a `ToolResultAIContent` wrapping a `ToolResultObject` for full control over result metadata. +#### Overriding Built-in Tools + +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), the runtime will return an error unless you explicitly opt in by setting `is_override` in the tool's `AdditionalProperties`. This flag signals that you intend to replace the built-in tool with your custom implementation. + +```csharp +var editFile = AIFunctionFactory.Create( + async ([Description("File path")] string path, [Description("New content")] string content) => { + // your logic + }, + "edit_file", + "Custom file editor with project-specific validation", + new AIFunctionFactoryOptions + { + AdditionalProperties = new ReadOnlyDictionary( + new Dictionary { ["is_override"] = true }) + }); + +var session = await client.CreateSessionAsync(new SessionConfig +{ + Model = "gpt-5", + Tools = [editFile], +}); +``` + ### System Message Customization Control the system prompt using `SystemMessage` in session config: diff --git a/dotnet/global.json b/dotnet/global.json new file mode 100644 index 000000000..c0c9c61a0 --- /dev/null +++ b/dotnet/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "major" + } +} diff --git a/dotnet/nuget.config b/dotnet/nuget.config new file mode 100644 index 000000000..128d95e59 --- /dev/null +++ b/dotnet/nuget.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dotnet/samples/Chat.csproj b/dotnet/samples/Chat.csproj index 4121ceaef..ad90a6062 100644 --- a/dotnet/samples/Chat.csproj +++ b/dotnet/samples/Chat.csproj @@ -1,9 +1,6 @@ Exe - net8.0 - enable - enable diff --git a/dotnet/src/ActionDisposable.cs b/dotnet/src/ActionDisposable.cs new file mode 100644 index 000000000..815904c12 --- /dev/null +++ b/dotnet/src/ActionDisposable.cs @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +namespace GitHub.Copilot.SDK; + +/// +/// A disposable that invokes an action when disposed. +/// +internal sealed class ActionDisposable(Action action) : IDisposable +{ + private Action? _action = action; + + public void Dispose() + { + var action = Interlocked.Exchange(ref _action, null); + action?.Invoke(); + } +} diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 8c70a4a2b..8cad6b048 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -16,6 +16,7 @@ using System.Text.Json.Serialization; using System.Text.RegularExpressions; using GitHub.Copilot.SDK.Rpc; +using System.Globalization; namespace GitHub.Copilot.SDK; @@ -38,7 +39,7 @@ namespace GitHub.Copilot.SDK; /// await using var client = new CopilotClient(); /// /// // Create a session -/// await using var session = await client.CreateSessionAsync(new SessionConfig { Model = "gpt-4" }); +/// await using var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll, Model = "gpt-4" }); /// /// // Handle events /// using var subscription = session.On(evt => @@ -51,8 +52,13 @@ namespace GitHub.Copilot.SDK; /// await session.SendAsync(new MessageOptions { Prompt = "Hello!" }); /// /// -public partial class CopilotClient : IDisposable, IAsyncDisposable +public sealed partial class CopilotClient : IDisposable, IAsyncDisposable { + /// + /// Minimum protocol version this SDK can communicate with. + /// + private const int MinProtocolVersion = 2; + private readonly ConcurrentDictionary _sessions = new(); private readonly CopilotClientOptions _options; private readonly ILogger _logger; @@ -60,10 +66,12 @@ public partial class CopilotClient : IDisposable, IAsyncDisposable private bool _disposed; private readonly int? _optionsPort; private readonly string? _optionsHost; + private int? _actualPort; + private int? _negotiatedProtocolVersion; private List? _modelsCache; private readonly SemaphoreSlim _modelsCacheLock = new(1, 1); - private readonly List> _lifecycleHandlers = new(); - private readonly Dictionary>> _typedLifecycleHandlers = new(); + private readonly List> _lifecycleHandlers = []; + private readonly Dictionary>> _typedLifecycleHandlers = []; private readonly object _lifecycleHandlersLock = new(); private ServerRpc? _rpc; @@ -79,6 +87,11 @@ public partial class CopilotClient : IDisposable, IAsyncDisposable ? throw new ObjectDisposedException(nameof(CopilotClient)) : _rpc ?? throw new InvalidOperationException("Client is not started. Call StartAsync first."); + /// + /// Gets the actual TCP port the CLI server is listening on, if using TCP transport. + /// + public int? ActualPort => _actualPort; + /// /// Creates a new instance of . /// @@ -117,9 +130,9 @@ public CopilotClient(CopilotClientOptions? options = null) } // Validate auth options with external server - if (!string.IsNullOrEmpty(_options.CliUrl) && (!string.IsNullOrEmpty(_options.GithubToken) || _options.UseLoggedInUser != null)) + if (!string.IsNullOrEmpty(_options.CliUrl) && (!string.IsNullOrEmpty(_options.GitHubToken) || _options.UseLoggedInUser != null)) { - throw new ArgumentException("GithubToken and UseLoggedInUser cannot be used with CliUrl (external server manages its own auth)"); + throw new ArgumentException("GitHubToken and UseLoggedInUser cannot be used with CliUrl (external server manages its own auth)"); } _logger = _options.Logger ?? NullLogger.Instance; @@ -190,12 +203,14 @@ async Task StartCoreAsync(CancellationToken ct) if (_optionsHost is not null && _optionsPort is not null) { // External server (TCP) + _actualPort = _optionsPort; result = ConnectToServerAsync(null, _optionsHost, _optionsPort, null, ct); } else { // Child process (stdio or TCP) var (cliProcess, portOrNull, stderrBuffer) = await StartCliServerAsync(_options, _logger, ct); + _actualPort = portOrNull; result = ConnectToServerAsync(cliProcess, portOrNull is null ? null : "localhost", portOrNull, stderrBuffer, ct); } @@ -210,18 +225,23 @@ async Task StartCoreAsync(CancellationToken ct) } /// - /// Disconnects from the Copilot server and stops all active sessions. + /// Disconnects from the Copilot server and closes all active sessions. /// /// A representing the asynchronous operation. /// /// /// This method performs graceful cleanup: /// - /// Destroys all active sessions + /// Closes all active sessions (releases in-memory resources) /// Closes the JSON-RPC connection /// Terminates the CLI server process (if spawned by this client) /// /// + /// + /// Note: session data on disk is preserved, so sessions can be resumed later. + /// To permanently remove session data before stopping, call + /// for each session first. + /// /// /// Thrown when multiple errors occur during cleanup. /// @@ -241,7 +261,7 @@ public async Task StopAsync() } catch (Exception ex) { - errors.Add(new Exception($"Failed to destroy session {session.SessionId}: {ex.Message}", ex)); + errors.Add(new Exception($"Failed to dispose session {session.SessionId}: {ex.Message}", ex)); } } @@ -340,10 +360,9 @@ private async Task CleanupConnectionAsync(List? errors) /// /// Creates a new Copilot session with the specified configuration. /// - /// Configuration for the session. If null, default settings are used. + /// Configuration for the session, including the required handler. /// A that can be used to cancel the operation. /// A task that resolves to provide the . - /// Thrown when the client is not connected and AutoStart is disabled, or when a session with the same ID already exists. /// /// Sessions maintain conversation state, handle events, and manage tool execution. /// If the client is not connected and is enabled (default), @@ -352,21 +371,29 @@ private async Task CleanupConnectionAsync(List? errors) /// /// /// // Basic session - /// var session = await client.CreateSessionAsync(); + /// var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// /// // Session with model and tools - /// var session = await client.CreateSessionAsync(new SessionConfig + /// var session = await client.CreateSessionAsync(new() /// { + /// OnPermissionRequest = PermissionHandler.ApproveAll, /// Model = "gpt-4", /// Tools = [AIFunctionFactory.Create(MyToolMethod)] /// }); /// /// - public async Task CreateSessionAsync(SessionConfig? config = null, CancellationToken cancellationToken = default) + public async Task CreateSessionAsync(SessionConfig config, CancellationToken cancellationToken = default) { + if (config.OnPermissionRequest == null) + { + throw new ArgumentException( + "An OnPermissionRequest handler is required when creating a session. " + + "For example, to allow all permissions, use CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll });"); + } + var connection = await EnsureConnectedAsync(cancellationToken); - var hasHooks = config?.Hooks != null && ( + var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || config.Hooks.OnPostToolUse != null || config.Hooks.OnUserPromptSubmitted != null || @@ -375,42 +402,39 @@ public async Task CreateSessionAsync(SessionConfig? config = nul config.Hooks.OnErrorOccurred != null); var request = new CreateSessionRequest( - config?.Model, - config?.SessionId, - config?.ClientName, - config?.ReasoningEffort, - config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), - config?.SystemMessage, - config?.AvailableTools, - config?.ExcludedTools, - config?.Provider, + config.Model, + config.SessionId, + config.ClientName, + config.ReasoningEffort, + config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), + config.SystemMessage, + config.AvailableTools, + config.ExcludedTools, + config.Provider, (bool?)true, - config?.OnUserInputRequest != null ? true : null, + config.OnUserInputRequest != null ? true : null, hasHooks ? true : null, - config?.WorkingDirectory, - config?.Streaming == true ? true : null, - config?.McpServers, + config.WorkingDirectory, + config.Streaming is true ? true : null, + config.McpServers, "direct", - config?.CustomAgents, - config?.ConfigDir, - config?.SkillDirectories, - config?.DisabledSkills, - config?.InfiniteSessions); + config.CustomAgents, + config.ConfigDir, + config.SkillDirectories, + config.DisabledSkills, + config.InfiniteSessions); var response = await InvokeRpcAsync( connection.Rpc, "session.create", [request], cancellationToken); var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath); - session.RegisterTools(config?.Tools ?? []); - if (config?.OnPermissionRequest != null) - { - session.RegisterPermissionHandler(config.OnPermissionRequest); - } - if (config?.OnUserInputRequest != null) + session.RegisterTools(config.Tools ?? []); + session.RegisterPermissionHandler(config.OnPermissionRequest); + if (config.OnUserInputRequest != null) { session.RegisterUserInputHandler(config.OnUserInputRequest); } - if (config?.Hooks != null) + if (config.Hooks != null) { session.RegisterHooks(config.Hooks); } @@ -427,9 +451,10 @@ public async Task CreateSessionAsync(SessionConfig? config = nul /// Resumes an existing Copilot session with the specified configuration. /// /// The ID of the session to resume. - /// Configuration for the resumed session. If null, default settings are used. + /// Configuration for the resumed session, including the required handler. /// A that can be used to cancel the operation. /// A task that resolves to provide the . + /// Thrown when is not set. /// Thrown when the session does not exist or the client is not connected. /// /// This allows you to continue a previous conversation, maintaining all conversation history. @@ -438,20 +463,28 @@ public async Task CreateSessionAsync(SessionConfig? config = nul /// /// /// // Resume a previous session - /// var session = await client.ResumeSessionAsync("session-123"); + /// var session = await client.ResumeSessionAsync("session-123", new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// /// // Resume with new tools - /// var session = await client.ResumeSessionAsync("session-123", new ResumeSessionConfig + /// var session = await client.ResumeSessionAsync("session-123", new() /// { + /// OnPermissionRequest = PermissionHandler.ApproveAll, /// Tools = [AIFunctionFactory.Create(MyNewToolMethod)] /// }); /// /// - public async Task ResumeSessionAsync(string sessionId, ResumeSessionConfig? config = null, CancellationToken cancellationToken = default) + public async Task ResumeSessionAsync(string sessionId, ResumeSessionConfig config, CancellationToken cancellationToken = default) { + if (config.OnPermissionRequest == null) + { + throw new ArgumentException( + "An OnPermissionRequest handler is required when resuming a session. " + + "For example, to allow all permissions, use new() { OnPermissionRequest = PermissionHandler.ApproveAll }."); + } + var connection = await EnsureConnectedAsync(cancellationToken); - var hasHooks = config?.Hooks != null && ( + var hasHooks = config.Hooks != null && ( config.Hooks.OnPreToolUse != null || config.Hooks.OnPostToolUse != null || config.Hooks.OnUserPromptSubmitted != null || @@ -461,42 +494,39 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes var request = new ResumeSessionRequest( sessionId, - config?.ClientName, - config?.Model, - config?.ReasoningEffort, - config?.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), - config?.SystemMessage, - config?.AvailableTools, - config?.ExcludedTools, - config?.Provider, + config.ClientName, + config.Model, + config.ReasoningEffort, + config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(), + config.SystemMessage, + config.AvailableTools, + config.ExcludedTools, + config.Provider, (bool?)true, - config?.OnUserInputRequest != null ? true : null, + config.OnUserInputRequest != null ? true : null, hasHooks ? true : null, - config?.WorkingDirectory, - config?.ConfigDir, - config?.DisableResume == true ? true : null, - config?.Streaming == true ? true : null, - config?.McpServers, + config.WorkingDirectory, + config.ConfigDir, + config.DisableResume is true ? true : null, + config.Streaming is true ? true : null, + config.McpServers, "direct", - config?.CustomAgents, - config?.SkillDirectories, - config?.DisabledSkills, - config?.InfiniteSessions); + config.CustomAgents, + config.SkillDirectories, + config.DisabledSkills, + config.InfiniteSessions); var response = await InvokeRpcAsync( connection.Rpc, "session.resume", [request], cancellationToken); var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath); - session.RegisterTools(config?.Tools ?? []); - if (config?.OnPermissionRequest != null) - { - session.RegisterPermissionHandler(config.OnPermissionRequest); - } - if (config?.OnUserInputRequest != null) + session.RegisterTools(config.Tools ?? []); + session.RegisterPermissionHandler(config.OnPermissionRequest); + if (config.OnUserInputRequest != null) { session.RegisterUserInputHandler(config.OnUserInputRequest); } - if (config?.Hooks != null) + if (config.Hooks != null) { session.RegisterHooks(config.Hooks); } @@ -516,7 +546,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes /// /// if (client.State == ConnectionState.Connected) /// { - /// var session = await client.CreateSessionAsync(); + /// var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// } /// /// @@ -601,7 +631,7 @@ public async Task> ListModelsAsync(CancellationToken cancellatio // Check cache (already inside lock) if (_modelsCache is not null) { - return new List(_modelsCache); // Return a copy to prevent cache mutation + return [.. _modelsCache]; // Return a copy to prevent cache mutation } // Cache miss - fetch from backend while holding lock @@ -611,7 +641,7 @@ public async Task> ListModelsAsync(CancellationToken cancellatio // Update cache before releasing lock _modelsCache = response.Models; - return new List(response.Models); // Return a copy to prevent cache mutation + return [.. response.Models]; // Return a copy to prevent cache mutation } finally { @@ -630,7 +660,7 @@ public async Task> ListModelsAsync(CancellationToken cancellatio /// var lastId = await client.GetLastSessionIdAsync(); /// if (lastId != null) /// { - /// var session = await client.ResumeSessionAsync(lastId); + /// var session = await client.ResumeSessionAsync(lastId, new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// } /// /// @@ -645,15 +675,17 @@ public async Task> ListModelsAsync(CancellationToken cancellatio } /// - /// Deletes a Copilot session by its ID. + /// Permanently deletes a session and all its data from disk, including + /// conversation history, planning state, and artifacts. /// /// The ID of the session to delete. /// A that can be used to cancel the operation. /// A task that represents the asynchronous delete operation. /// Thrown when the session does not exist or deletion fails. /// - /// This permanently removes the session and all its conversation history. - /// The session cannot be resumed after deletion. + /// Unlike , which only releases in-memory + /// resources and preserves session data for later resumption, this method is + /// irreversible. The session cannot be resumed after deletion. /// /// /// @@ -810,7 +842,7 @@ public IDisposable On(string eventType, Action handler) { if (!_typedLifecycleHandlers.TryGetValue(eventType, out var handlers)) { - handlers = new List>(); + handlers = []; _typedLifecycleHandlers[eventType] = handlers; } handlers.Add(handler); @@ -836,9 +868,9 @@ private void DispatchLifecycleEvent(SessionLifecycleEvent evt) lock (_lifecycleHandlersLock) { typedHandlers = _typedLifecycleHandlers.TryGetValue(evt.Type, out var handlers) - ? new List>(handlers) - : new List>(); - wildcardHandlers = new List>(_lifecycleHandlers); + ? [.. handlers] + : []; + wildcardHandlers = [.. _lifecycleHandlers]; } foreach (var handler in typedHandlers) @@ -899,25 +931,28 @@ private Task EnsureConnectedAsync(CancellationToken cancellationToke private async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken) { - var expectedVersion = SdkProtocolVersion.GetVersion(); + var maxVersion = SdkProtocolVersion.GetVersion(); var pingResponse = await InvokeRpcAsync( connection.Rpc, "ping", [new PingRequest()], connection.StderrBuffer, cancellationToken); if (!pingResponse.ProtocolVersion.HasValue) { throw new InvalidOperationException( - $"SDK protocol version mismatch: SDK expects version {expectedVersion}, " + + $"SDK protocol version mismatch: SDK supports versions {MinProtocolVersion}-{maxVersion}, " + $"but server does not report a protocol version. " + $"Please update your server to ensure compatibility."); } - if (pingResponse.ProtocolVersion.Value != expectedVersion) + var serverVersion = pingResponse.ProtocolVersion.Value; + if (serverVersion < MinProtocolVersion || serverVersion > maxVersion) { throw new InvalidOperationException( - $"SDK protocol version mismatch: SDK expects version {expectedVersion}, " + - $"but server reports version {pingResponse.ProtocolVersion.Value}. " + + $"SDK protocol version mismatch: SDK supports versions {MinProtocolVersion}-{maxVersion}, " + + $"but server reports version {serverVersion}. " + $"Please update your SDK or server to ensure compatibility."); } + + _negotiatedProtocolVersion = serverVersion; } private static async Task<(Process Process, int? DetectedLocalhostTcpPort, StringBuilder StderrBuffer)> StartCliServerAsync(CopilotClientOptions options, ILogger logger, CancellationToken cancellationToken) @@ -940,17 +975,17 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio } else if (options.Port > 0) { - args.AddRange(["--port", options.Port.ToString()]); + args.AddRange(["--port", options.Port.ToString(CultureInfo.InvariantCulture)]); } // Add auth-related flags - if (!string.IsNullOrEmpty(options.GithubToken)) + if (!string.IsNullOrEmpty(options.GitHubToken)) { args.AddRange(["--auth-token-env", "COPILOT_SDK_AUTH_TOKEN"]); } - // Default UseLoggedInUser to false when GithubToken is provided - var useLoggedInUser = options.UseLoggedInUser ?? string.IsNullOrEmpty(options.GithubToken); + // Default UseLoggedInUser to false when GitHubToken is provided + var useLoggedInUser = options.UseLoggedInUser ?? string.IsNullOrEmpty(options.GitHubToken); if (!useLoggedInUser) { args.Add("--no-auto-login"); @@ -982,9 +1017,9 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio startInfo.Environment.Remove("NODE_DEBUG"); // Set auth token in environment if provided - if (!string.IsNullOrEmpty(options.GithubToken)) + if (!string.IsNullOrEmpty(options.GitHubToken)) { - startInfo.Environment["COPILOT_SDK_AUTH_TOKEN"] = options.GithubToken; + startInfo.Environment["COPILOT_SDK_AUTH_TOKEN"] = options.GitHubToken; } var cliProcess = new Process { StartInfo = startInfo }; @@ -1003,7 +1038,11 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio { stderrBuffer.AppendLine(line); } - logger.LogDebug("[CLI] {Line}", line); + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("[CLI] {Line}", line); + } } } }, cancellationToken); @@ -1017,13 +1056,10 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio while (!cts.Token.IsCancellationRequested) { - var line = await cliProcess.StandardOutput.ReadLineAsync(cts.Token); - if (line == null) throw new Exception("CLI process exited unexpectedly"); - - var match = Regex.Match(line, @"listening on port (\d+)", RegexOptions.IgnoreCase); - if (match.Success) + var line = await cliProcess.StandardOutput.ReadLineAsync(cts.Token) ?? throw new IOException("CLI process exited unexpectedly"); + if (ListeningOnPortRegex().Match(line) is { Success: true } match) { - detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value); + detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); break; } } @@ -1110,8 +1146,12 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? var handler = new RpcHandler(this); rpc.AddLocalRpcMethod("session.event", handler.OnSessionEvent); rpc.AddLocalRpcMethod("session.lifecycle", handler.OnSessionLifecycle); - rpc.AddLocalRpcMethod("tool.call", handler.OnToolCall); - rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequest); + // Protocol v3 servers send tool calls / permission requests as broadcast events. + // Protocol v2 servers use the older tool.call / permission.request RPC model. + // We always register v2 adapters because handlers are set up before version + // negotiation; a v3 server will simply never send these requests. + rpc.AddLocalRpcMethod("tool.call", handler.OnToolCallV2); + rpc.AddLocalRpcMethod("permission.request", handler.OnPermissionRequestV2); rpc.AddLocalRpcMethod("userInput.request", handler.OnUserInputRequest); rpc.AddLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); rpc.StartListening(); @@ -1123,8 +1163,10 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")] - private static SystemTextJsonFormatter CreateSystemTextJsonFormatter() => - new SystemTextJsonFormatter() { JsonSerializerOptions = SerializerOptionsForMessageFormatter }; + private static SystemTextJsonFormatter CreateSystemTextJsonFormatter() + { + return new() { JsonSerializerOptions = SerializerOptionsForMessageFormatter }; + } private static JsonSerializerOptions SerializerOptionsForMessageFormatter { get; } = CreateSerializerOptions(); @@ -1147,8 +1189,10 @@ private static JsonSerializerOptions CreateSerializerOptions() return options; } - internal CopilotSession? GetSession(string sessionId) => - _sessions.TryGetValue(sessionId, out var session) ? session : null; + internal CopilotSession? GetSession(string sessionId) + { + return _sessions.TryGetValue(sessionId, out var session) ? session : null; + } /// /// Disposes the synchronously. @@ -1158,7 +1202,7 @@ private static JsonSerializerOptions CreateSerializerOptions() /// public void Dispose() { - DisposeAsync().GetAwaiter().GetResult(); + DisposeAsync().AsTask().GetAwaiter().GetResult(); } /// @@ -1208,20 +1252,38 @@ public void OnSessionLifecycle(string type, string sessionId, JsonElement? metad client.DispatchLifecycleEvent(evt); } - public async Task OnToolCall(string sessionId, + public async Task OnUserInputRequest(string sessionId, string question, List? choices = null, bool? allowFreeform = null) + { + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); + var request = new UserInputRequest + { + Question = question, + Choices = choices, + AllowFreeform = allowFreeform + }; + + var result = await session.HandleUserInputRequestAsync(request); + return new UserInputRequestResponse(result.Answer, result.WasFreeform); + } + + public async Task OnHooksInvoke(string sessionId, string hookType, JsonElement input) + { + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); + var output = await session.HandleHooksInvokeAsync(hookType, input); + return new HooksInvokeResponse(output); + } + + // Protocol v2 backward-compatibility adapters + + public async Task OnToolCallV2(string sessionId, string toolCallId, string toolName, object? arguments) { - var session = client.GetSession(sessionId); - if (session == null) - { - throw new ArgumentException($"Unknown session {sessionId}"); - } - + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); if (session.GetTool(toolName) is not { } tool) { - return new ToolCallResponse(new ToolResultObject + return new ToolCallResponseV2(new ToolResultObject { TextResultForLlm = $"Tool '{toolName}' is not supported.", ResultType = "failure", @@ -1239,14 +1301,10 @@ public async Task OnToolCall(string sessionId, Arguments = arguments }; - // Map args from JSON into AIFunction format var aiFunctionArgs = new AIFunctionArguments { Context = new Dictionary { - // Allow recipient to access the raw ToolInvocation if they want, e.g., to get SessionId - // This is an alternative to using MEAI's ConfigureParameterBinding, which we can't use - // because we're not the ones producing the AIFunction. [typeof(ToolInvocation)] = invocation } }; @@ -1260,99 +1318,50 @@ public async Task OnToolCall(string sessionId, foreach (var prop in incomingJsonArgs.EnumerateObject()) { - // MEAI will deserialize the JsonElement value respecting the delegate's parameter types aiFunctionArgs[prop.Name] = prop.Value; } } var result = await tool.InvokeAsync(aiFunctionArgs); - // If the function returns a ToolResultObject, use it directly; otherwise, wrap the result - // This lets the developer provide BinaryResult, SessionLog, etc. if they deal with that themselves var toolResultObject = result is ToolResultAIContent trac ? trac.Result : new ToolResultObject { ResultType = "success", - - // In most cases, result will already have been converted to JsonElement by the AIFunction. - // We special-case string for consistency with our Node/Python/Go clients. - // TODO: I don't think it's right to special-case string here, and all the clients should - // always serialize the result to JSON (otherwise what stringification is going to happen? - // something we don't control? an error?) TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je ? je.GetString()! : JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))), }; - return new ToolCallResponse(toolResultObject); + return new ToolCallResponseV2(toolResultObject); } catch (Exception ex) { - return new ToolCallResponse(new() + return new ToolCallResponseV2(new ToolResultObject { - // TODO: We should offer some way to control whether or not to expose detailed exception information to the LLM. - // For security, the default must be false, but developers can opt into allowing it. - TextResultForLlm = $"Invoking this tool produced an error. Detailed information is not available.", + TextResultForLlm = "Invoking this tool produced an error. Detailed information is not available.", ResultType = "failure", Error = ex.Message }); } } - public async Task OnPermissionRequest(string sessionId, JsonElement permissionRequest) + public async Task OnPermissionRequestV2(string sessionId, JsonElement permissionRequest) { - var session = client.GetSession(sessionId); - if (session == null) - { - return new PermissionRequestResponse(new PermissionRequestResult - { - Kind = "denied-no-approval-rule-and-could-not-request-from-user" - }); - } + var session = client.GetSession(sessionId) + ?? throw new ArgumentException($"Unknown session {sessionId}"); try { var result = await session.HandlePermissionRequestAsync(permissionRequest); - return new PermissionRequestResponse(result); + return new PermissionRequestResponseV2(result); } - catch + catch (Exception) { - // If permission handler fails, deny the permission - return new PermissionRequestResponse(new PermissionRequestResult + return new PermissionRequestResponseV2(new PermissionRequestResult { - Kind = "denied-no-approval-rule-and-could-not-request-from-user" + Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser }); } } - - public async Task OnUserInputRequest(string sessionId, string question, List? choices = null, bool? allowFreeform = null) - { - var session = client.GetSession(sessionId); - if (session == null) - { - throw new ArgumentException($"Unknown session {sessionId}"); - } - - var request = new UserInputRequest - { - Question = question, - Choices = choices, - AllowFreeform = allowFreeform - }; - - var result = await session.HandleUserInputRequestAsync(request); - return new UserInputRequestResponse(result.Answer, result.WasFreeform); - } - - public async Task OnHooksInvoke(string sessionId, string hookType, JsonElement input) - { - var session = client.GetSession(sessionId); - if (session == null) - { - throw new ArgumentException($"Unknown session {sessionId}"); - } - - var output = await session.HandleHooksInvokeAsync(hookType, input); - return new HooksInvokeResponse(output); - } } private class Connection( @@ -1406,10 +1415,15 @@ internal record CreateSessionRequest( internal record ToolDefinition( string Name, string? Description, - JsonElement Parameters /* JSON schema */) + JsonElement Parameters, /* JSON schema */ + bool? OverridesBuiltInTool = null) { public static ToolDefinition FromAIFunction(AIFunction function) - => new ToolDefinition(function.Name, function.Description, function.JsonSchema); + { + var overrides = function.AdditionalProperties.TryGetValue("is_override", out var val) && val is true; + return new ToolDefinition(function.Name, function.Description, function.JsonSchema, + overrides ? true : null); + } } internal record CreateSessionResponse( @@ -1460,12 +1474,6 @@ internal record ListSessionsRequest( internal record ListSessionsResponse( List Sessions); - internal record ToolCallResponse( - ToolResultObject? Result); - - internal record PermissionRequestResponse( - PermissionRequestResult Result); - internal record UserInputRequestResponse( string Answer, bool WasFreeform); @@ -1473,6 +1481,13 @@ internal record UserInputRequestResponse( internal record HooksInvokeResponse( object? Output); + // Protocol v2 backward-compatibility response types + internal record ToolCallResponseV2( + ToolResultObject Result); + + internal record PermissionRequestResponseV2( + PermissionRequestResult Result); + /// Trace source that forwards all logs to the ILogger. internal sealed class LoggerTraceSource : TraceSource { @@ -1484,33 +1499,70 @@ public LoggerTraceSource(ILogger logger) : base(nameof(LoggerTraceSource), Sourc private sealed class LoggerTraceListener(ILogger logger) : TraceListener { - public override void TraceEvent(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, string? message) => - logger.Log(MapLevel(eventType), "[{Source}] {Message}", source, message); + public override void TraceEvent(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, string? message) + { + LogLevel level = MapLevel(eventType); + if (logger.IsEnabled(level)) + { + logger.Log(level, "[{Source}] {Message}", source, message); + } + } - public override void TraceEvent(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, string? format, params object?[]? args) => - logger.Log(MapLevel(eventType), "[{Source}] {Message}", source, args is null || args.Length == 0 ? format : string.Format(format ?? "", args)); + public override void TraceEvent(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, string? format, params object?[]? args) + { + LogLevel level = MapLevel(eventType); + if (logger.IsEnabled(level)) + { + logger.Log(level, "[{Source}] {Message}", source, args is null || args.Length == 0 ? format : string.Format(CultureInfo.InvariantCulture, format ?? "", args)); + } + } - public override void TraceData(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, object? data) => - logger.Log(MapLevel(eventType), "[{Source}] {Data}", source, data); + public override void TraceData(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, object? data) + { + LogLevel level = MapLevel(eventType); + if (logger.IsEnabled(level)) + { + logger.Log(level, "[{Source}] {Data}", source, data); + } + } - public override void TraceData(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, params object?[]? data) => - logger.Log(MapLevel(eventType), "[{Source}] {Data}", source, data is null ? null : string.Join(", ", data)); + public override void TraceData(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, params object?[]? data) + { + LogLevel level = MapLevel(eventType); + if (logger.IsEnabled(level)) + { + logger.Log(level, "[{Source}] {Data}", source, data is null ? null : string.Join(", ", data)); + } + } - public override void Write(string? message) => - logger.LogTrace("{Message}", message); + public override void Write(string? message) + { + if (logger.IsEnabled(LogLevel.Trace)) + { + logger.LogTrace("{Message}", message); + } + } - public override void WriteLine(string? message) => - logger.LogTrace("{Message}", message); + public override void WriteLine(string? message) + { + if (logger.IsEnabled(LogLevel.Trace)) + { + logger.LogTrace("{Message}", message); + } + } - private static LogLevel MapLevel(TraceEventType eventType) => eventType switch + private static LogLevel MapLevel(TraceEventType eventType) { - TraceEventType.Critical => LogLevel.Critical, - TraceEventType.Error => LogLevel.Error, - TraceEventType.Warning => LogLevel.Warning, - TraceEventType.Information => LogLevel.Information, - TraceEventType.Verbose => LogLevel.Debug, - _ => LogLevel.Trace - }; + return eventType switch + { + TraceEventType.Critical => LogLevel.Critical, + TraceEventType.Error => LogLevel.Error, + TraceEventType.Warning => LogLevel.Warning, + TraceEventType.Information => LogLevel.Information, + TraceEventType.Verbose => LogLevel.Debug, + _ => LogLevel.Trace + }; + } } } @@ -1528,14 +1580,14 @@ public override void WriteLine(string? message) => [JsonSerializable(typeof(HooksInvokeResponse))] [JsonSerializable(typeof(ListSessionsRequest))] [JsonSerializable(typeof(ListSessionsResponse))] - [JsonSerializable(typeof(PermissionRequestResponse))] [JsonSerializable(typeof(PermissionRequestResult))] + [JsonSerializable(typeof(PermissionRequestResponseV2))] [JsonSerializable(typeof(ProviderConfig))] [JsonSerializable(typeof(ResumeSessionRequest))] [JsonSerializable(typeof(ResumeSessionResponse))] [JsonSerializable(typeof(SessionMetadata))] [JsonSerializable(typeof(SystemMessageConfig))] - [JsonSerializable(typeof(ToolCallResponse))] + [JsonSerializable(typeof(ToolCallResponseV2))] [JsonSerializable(typeof(ToolDefinition))] [JsonSerializable(typeof(ToolResultAIContent))] [JsonSerializable(typeof(ToolResultObject))] @@ -1543,30 +1595,20 @@ public override void WriteLine(string? message) => [JsonSerializable(typeof(UserInputRequest))] [JsonSerializable(typeof(UserInputResponse))] internal partial class ClientJsonContext : JsonSerializerContext; -} -// Must inherit from AIContent as a signal to MEAI to avoid JSON-serializing the -// value before passing it back to us -public class ToolResultAIContent(ToolResultObject toolResult) : AIContent -{ - public ToolResultObject Result => toolResult; + [GeneratedRegex(@"listening on port ([0-9]+)", RegexOptions.IgnoreCase)] + private static partial Regex ListeningOnPortRegex(); } /// -/// A disposable that invokes an action when disposed. +/// Wraps a as to pass structured tool results +/// back through Microsoft.Extensions.AI without JSON serialization. /// -internal sealed class ActionDisposable : IDisposable +/// The tool result to wrap. +public class ToolResultAIContent(ToolResultObject toolResult) : AIContent { - private Action? _action; - - public ActionDisposable(Action action) - { - _action = action; - } - - public void Dispose() - { - var action = Interlocked.Exchange(ref _action, null); - action?.Invoke(); - } + /// + /// Gets the underlying . + /// + public ToolResultObject Result => toolResult; } diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index ac010ed86..85e55e4b8 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -5,6 +5,9 @@ // AUTO-GENERATED FILE - DO NOT EDIT // Generated from: api.schema.json +// Generated code does not have XML doc comments; suppress CS1591 to avoid warnings. +#pragma warning disable CS1591 + using System.Text.Json; using System.Text.Json.Serialization; using StreamJsonRpc; @@ -35,11 +38,11 @@ internal class PingRequest public class ModelCapabilitiesSupports { [JsonPropertyName("vision")] - public bool Vision { get; set; } + public bool? Vision { get; set; } /// Whether this model supports reasoning effort configuration [JsonPropertyName("reasoningEffort")] - public bool ReasoningEffort { get; set; } + public bool? ReasoningEffort { get; set; } } public class ModelCapabilitiesLimits @@ -116,7 +119,7 @@ public class ModelsListResult { /// List of available models with full metadata [JsonPropertyName("models")] - public List Models { get; set; } = new(); + public List Models { get; set; } = []; } public class Tool @@ -146,10 +149,10 @@ public class ToolsListResult { /// List of available built-in tools with metadata [JsonPropertyName("tools")] - public List Tools { get; set; } = new(); + public List Tools { get; set; } = []; } -internal class ListRequest +internal class ToolsListRequest { [JsonPropertyName("model")] public string? Model { get; set; } @@ -186,7 +189,7 @@ public class AccountGetQuotaResult { /// Quota snapshots keyed by type (e.g., chat, completions, premium_interactions) [JsonPropertyName("quotaSnapshots")] - public Dictionary QuotaSnapshots { get; set; } = new(); + public Dictionary QuotaSnapshots { get; set; } = []; } public class SessionModelGetCurrentResult @@ -195,7 +198,7 @@ public class SessionModelGetCurrentResult public string? ModelId { get; set; } } -internal class GetCurrentRequest +internal class SessionModelGetCurrentRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -207,7 +210,7 @@ public class SessionModelSwitchToResult public string? ModelId { get; set; } } -internal class SwitchToRequest +internal class SessionModelSwitchToRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -223,7 +226,7 @@ public class SessionModeGetResult public SessionModeGetResultMode Mode { get; set; } } -internal class GetRequest +internal class SessionModeGetRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -236,7 +239,7 @@ public class SessionModeSetResult public SessionModeGetResultMode Mode { get; set; } } -internal class SetRequest +internal class SessionModeSetRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -247,16 +250,20 @@ internal class SetRequest public class SessionPlanReadResult { - /// Whether plan.md exists in the workspace + /// Whether the plan file exists in the workspace [JsonPropertyName("exists")] public bool Exists { get; set; } - /// The content of plan.md, or null if it does not exist + /// The content of the plan file, or null if it does not exist [JsonPropertyName("content")] public string? Content { get; set; } + + /// Absolute file path of the plan file, or null if workspace is not enabled + [JsonPropertyName("path")] + public string? Path { get; set; } } -internal class ReadRequest +internal class SessionPlanReadRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -266,7 +273,7 @@ public class SessionPlanUpdateResult { } -internal class UpdateRequest +internal class SessionPlanUpdateRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -279,7 +286,7 @@ public class SessionPlanDeleteResult { } -internal class DeleteRequest +internal class SessionPlanDeleteRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -289,10 +296,10 @@ public class SessionWorkspaceListFilesResult { /// Relative file paths in the workspace files directory [JsonPropertyName("files")] - public List Files { get; set; } = new(); + public List Files { get; set; } = []; } -internal class ListFilesRequest +internal class SessionWorkspaceListFilesRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -305,7 +312,7 @@ public class SessionWorkspaceReadFileResult public string Content { get; set; } = string.Empty; } -internal class ReadFileRequest +internal class SessionWorkspaceReadFileRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -318,7 +325,7 @@ public class SessionWorkspaceCreateFileResult { } -internal class CreateFileRequest +internal class SessionWorkspaceCreateFileRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -337,7 +344,7 @@ public class SessionFleetStartResult public bool Started { get; set; } } -internal class StartRequest +internal class SessionFleetStartRequest { [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; @@ -346,6 +353,164 @@ internal class StartRequest public string? Prompt { get; set; } } +public class Agent +{ + /// Unique identifier of the custom agent + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Human-readable display name + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// Description of the agent's purpose + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; +} + +public class SessionAgentListResult +{ + /// Available custom agents + [JsonPropertyName("agents")] + public List Agents { get; set; } = []; +} + +internal class SessionAgentListRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +public class SessionAgentGetCurrentResultAgent +{ + /// Unique identifier of the custom agent + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Human-readable display name + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// Description of the agent's purpose + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; +} + +public class SessionAgentGetCurrentResult +{ + /// Currently selected custom agent, or null if using the default agent + [JsonPropertyName("agent")] + public SessionAgentGetCurrentResultAgent? Agent { get; set; } +} + +internal class SessionAgentGetCurrentRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +/// The newly selected custom agent +public class SessionAgentSelectResultAgent +{ + /// Unique identifier of the custom agent + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Human-readable display name + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// Description of the agent's purpose + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; +} + +public class SessionAgentSelectResult +{ + /// The newly selected custom agent + [JsonPropertyName("agent")] + public SessionAgentSelectResultAgent Agent { get; set; } = new(); +} + +internal class SessionAgentSelectRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; +} + +public class SessionAgentDeselectResult +{ +} + +internal class SessionAgentDeselectRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +public class SessionCompactionCompactResult +{ + /// Whether compaction completed successfully + [JsonPropertyName("success")] + public bool Success { get; set; } + + /// Number of tokens freed by compaction + [JsonPropertyName("tokensRemoved")] + public double TokensRemoved { get; set; } + + /// Number of messages removed during compaction + [JsonPropertyName("messagesRemoved")] + public double MessagesRemoved { get; set; } +} + +internal class SessionCompactionCompactRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; +} + +public class SessionToolsHandlePendingToolCallResult +{ + [JsonPropertyName("success")] + public bool Success { get; set; } +} + +internal class SessionToolsHandlePendingToolCallRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = string.Empty; + + [JsonPropertyName("result")] + public object? Result { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } +} + +public class SessionPermissionsHandlePendingPermissionRequestResult +{ + [JsonPropertyName("success")] + public bool Success { get; set; } +} + +internal class SessionPermissionsHandlePendingPermissionRequestRequest +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = string.Empty; + + [JsonPropertyName("result")] + public object Result { get; set; } = null!; +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum SessionModeGetResultMode { @@ -366,9 +531,9 @@ public class ServerRpc internal ServerRpc(JsonRpc rpc) { _rpc = rpc; - Models = new ModelsApi(rpc); - Tools = new ToolsApi(rpc); - Account = new AccountApi(rpc); + Models = new ServerModelsApi(rpc); + Tools = new ServerToolsApi(rpc); + Account = new ServerAccountApi(rpc); } /// Calls "ping". @@ -379,21 +544,21 @@ public async Task PingAsync(string? message = null, CancellationToke } /// Models APIs. - public ModelsApi Models { get; } + public ServerModelsApi Models { get; } /// Tools APIs. - public ToolsApi Tools { get; } + public ServerToolsApi Tools { get; } /// Account APIs. - public AccountApi Account { get; } + public ServerAccountApi Account { get; } } /// Server-scoped Models APIs. -public class ModelsApi +public class ServerModelsApi { private readonly JsonRpc _rpc; - internal ModelsApi(JsonRpc rpc) + internal ServerModelsApi(JsonRpc rpc) { _rpc = rpc; } @@ -406,11 +571,11 @@ public async Task ListAsync(CancellationToken cancellationToke } /// Server-scoped Tools APIs. -public class ToolsApi +public class ServerToolsApi { private readonly JsonRpc _rpc; - internal ToolsApi(JsonRpc rpc) + internal ServerToolsApi(JsonRpc rpc) { _rpc = rpc; } @@ -418,17 +583,17 @@ internal ToolsApi(JsonRpc rpc) /// Calls "tools.list". public async Task ListAsync(string? model = null, CancellationToken cancellationToken = default) { - var request = new ListRequest { Model = model }; + var request = new ToolsListRequest { Model = model }; return await CopilotClient.InvokeRpcAsync(_rpc, "tools.list", [request], cancellationToken); } } /// Server-scoped Account APIs. -public class AccountApi +public class ServerAccountApi { private readonly JsonRpc _rpc; - internal AccountApi(JsonRpc rpc) + internal ServerAccountApi(JsonRpc rpc) { _rpc = rpc; } @@ -455,6 +620,10 @@ internal SessionRpc(JsonRpc rpc, string sessionId) Plan = new PlanApi(rpc, sessionId); Workspace = new WorkspaceApi(rpc, sessionId); Fleet = new FleetApi(rpc, sessionId); + Agent = new AgentApi(rpc, sessionId); + Compaction = new CompactionApi(rpc, sessionId); + Tools = new ToolsApi(rpc, sessionId); + Permissions = new PermissionsApi(rpc, sessionId); } public ModelApi Model { get; } @@ -466,6 +635,14 @@ internal SessionRpc(JsonRpc rpc, string sessionId) public WorkspaceApi Workspace { get; } public FleetApi Fleet { get; } + + public AgentApi Agent { get; } + + public CompactionApi Compaction { get; } + + public ToolsApi Tools { get; } + + public PermissionsApi Permissions { get; } } public class ModelApi @@ -482,14 +659,14 @@ internal ModelApi(JsonRpc rpc, string sessionId) /// Calls "session.model.getCurrent". public async Task GetCurrentAsync(CancellationToken cancellationToken = default) { - var request = new GetCurrentRequest { SessionId = _sessionId }; + var request = new SessionModelGetCurrentRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.model.getCurrent", [request], cancellationToken); } /// Calls "session.model.switchTo". public async Task SwitchToAsync(string modelId, CancellationToken cancellationToken = default) { - var request = new SwitchToRequest { SessionId = _sessionId, ModelId = modelId }; + var request = new SessionModelSwitchToRequest { SessionId = _sessionId, ModelId = modelId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.model.switchTo", [request], cancellationToken); } } @@ -508,14 +685,14 @@ internal ModeApi(JsonRpc rpc, string sessionId) /// Calls "session.mode.get". public async Task GetAsync(CancellationToken cancellationToken = default) { - var request = new GetRequest { SessionId = _sessionId }; + var request = new SessionModeGetRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.mode.get", [request], cancellationToken); } /// Calls "session.mode.set". public async Task SetAsync(SessionModeGetResultMode mode, CancellationToken cancellationToken = default) { - var request = new SetRequest { SessionId = _sessionId, Mode = mode }; + var request = new SessionModeSetRequest { SessionId = _sessionId, Mode = mode }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.mode.set", [request], cancellationToken); } } @@ -534,21 +711,21 @@ internal PlanApi(JsonRpc rpc, string sessionId) /// Calls "session.plan.read". public async Task ReadAsync(CancellationToken cancellationToken = default) { - var request = new ReadRequest { SessionId = _sessionId }; + var request = new SessionPlanReadRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.plan.read", [request], cancellationToken); } /// Calls "session.plan.update". public async Task UpdateAsync(string content, CancellationToken cancellationToken = default) { - var request = new UpdateRequest { SessionId = _sessionId, Content = content }; + var request = new SessionPlanUpdateRequest { SessionId = _sessionId, Content = content }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.plan.update", [request], cancellationToken); } /// Calls "session.plan.delete". public async Task DeleteAsync(CancellationToken cancellationToken = default) { - var request = new DeleteRequest { SessionId = _sessionId }; + var request = new SessionPlanDeleteRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.plan.delete", [request], cancellationToken); } } @@ -567,21 +744,21 @@ internal WorkspaceApi(JsonRpc rpc, string sessionId) /// Calls "session.workspace.listFiles". public async Task ListFilesAsync(CancellationToken cancellationToken = default) { - var request = new ListFilesRequest { SessionId = _sessionId }; + var request = new SessionWorkspaceListFilesRequest { SessionId = _sessionId }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.workspace.listFiles", [request], cancellationToken); } /// Calls "session.workspace.readFile". public async Task ReadFileAsync(string path, CancellationToken cancellationToken = default) { - var request = new ReadFileRequest { SessionId = _sessionId, Path = path }; + var request = new SessionWorkspaceReadFileRequest { SessionId = _sessionId, Path = path }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.workspace.readFile", [request], cancellationToken); } /// Calls "session.workspace.createFile". public async Task CreateFileAsync(string path, string content, CancellationToken cancellationToken = default) { - var request = new CreateFileRequest { SessionId = _sessionId, Path = path, Content = content }; + var request = new SessionWorkspaceCreateFileRequest { SessionId = _sessionId, Path = path, Content = content }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.workspace.createFile", [request], cancellationToken); } } @@ -600,23 +777,115 @@ internal FleetApi(JsonRpc rpc, string sessionId) /// Calls "session.fleet.start". public async Task StartAsync(string? prompt, CancellationToken cancellationToken = default) { - var request = new StartRequest { SessionId = _sessionId, Prompt = prompt }; + var request = new SessionFleetStartRequest { SessionId = _sessionId, Prompt = prompt }; return await CopilotClient.InvokeRpcAsync(_rpc, "session.fleet.start", [request], cancellationToken); } } +public class AgentApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal AgentApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.agent.list". + public async Task ListAsync(CancellationToken cancellationToken = default) + { + var request = new SessionAgentListRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.agent.list", [request], cancellationToken); + } + + /// Calls "session.agent.getCurrent". + public async Task GetCurrentAsync(CancellationToken cancellationToken = default) + { + var request = new SessionAgentGetCurrentRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.agent.getCurrent", [request], cancellationToken); + } + + /// Calls "session.agent.select". + public async Task SelectAsync(string name, CancellationToken cancellationToken = default) + { + var request = new SessionAgentSelectRequest { SessionId = _sessionId, Name = name }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.agent.select", [request], cancellationToken); + } + + /// Calls "session.agent.deselect". + public async Task DeselectAsync(CancellationToken cancellationToken = default) + { + var request = new SessionAgentDeselectRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.agent.deselect", [request], cancellationToken); + } +} + +public class CompactionApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal CompactionApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.compaction.compact". + public async Task CompactAsync(CancellationToken cancellationToken = default) + { + var request = new SessionCompactionCompactRequest { SessionId = _sessionId }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.compaction.compact", [request], cancellationToken); + } +} + +public class ToolsApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal ToolsApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.tools.handlePendingToolCall". + public async Task HandlePendingToolCallAsync(string requestId, object? result, string? error, CancellationToken cancellationToken = default) + { + var request = new SessionToolsHandlePendingToolCallRequest { SessionId = _sessionId, RequestId = requestId, Result = result, Error = error }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.tools.handlePendingToolCall", [request], cancellationToken); + } +} + +public class PermissionsApi +{ + private readonly JsonRpc _rpc; + private readonly string _sessionId; + + internal PermissionsApi(JsonRpc rpc, string sessionId) + { + _rpc = rpc; + _sessionId = sessionId; + } + + /// Calls "session.permissions.handlePendingPermissionRequest". + public async Task HandlePendingPermissionRequestAsync(string requestId, object result, CancellationToken cancellationToken = default) + { + var request = new SessionPermissionsHandlePendingPermissionRequestRequest { SessionId = _sessionId, RequestId = requestId, Result = result }; + return await CopilotClient.InvokeRpcAsync(_rpc, "session.permissions.handlePendingPermissionRequest", [request], cancellationToken); + } +} + [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(AccountGetQuotaResult))] [JsonSerializable(typeof(AccountGetQuotaResultQuotaSnapshotsValue))] -[JsonSerializable(typeof(CreateFileRequest))] -[JsonSerializable(typeof(DeleteRequest))] -[JsonSerializable(typeof(GetCurrentRequest))] -[JsonSerializable(typeof(GetRequest))] -[JsonSerializable(typeof(ListFilesRequest))] -[JsonSerializable(typeof(ListRequest))] +[JsonSerializable(typeof(Agent))] [JsonSerializable(typeof(Model))] [JsonSerializable(typeof(ModelBilling))] [JsonSerializable(typeof(ModelCapabilities))] @@ -626,23 +895,45 @@ public async Task StartAsync(string? prompt, Cancellati [JsonSerializable(typeof(ModelsListResult))] [JsonSerializable(typeof(PingRequest))] [JsonSerializable(typeof(PingResult))] -[JsonSerializable(typeof(ReadFileRequest))] -[JsonSerializable(typeof(ReadRequest))] +[JsonSerializable(typeof(SessionAgentDeselectRequest))] +[JsonSerializable(typeof(SessionAgentDeselectResult))] +[JsonSerializable(typeof(SessionAgentGetCurrentRequest))] +[JsonSerializable(typeof(SessionAgentGetCurrentResult))] +[JsonSerializable(typeof(SessionAgentGetCurrentResultAgent))] +[JsonSerializable(typeof(SessionAgentListRequest))] +[JsonSerializable(typeof(SessionAgentListResult))] +[JsonSerializable(typeof(SessionAgentSelectRequest))] +[JsonSerializable(typeof(SessionAgentSelectResult))] +[JsonSerializable(typeof(SessionAgentSelectResultAgent))] +[JsonSerializable(typeof(SessionCompactionCompactRequest))] +[JsonSerializable(typeof(SessionCompactionCompactResult))] +[JsonSerializable(typeof(SessionFleetStartRequest))] [JsonSerializable(typeof(SessionFleetStartResult))] +[JsonSerializable(typeof(SessionModeGetRequest))] [JsonSerializable(typeof(SessionModeGetResult))] +[JsonSerializable(typeof(SessionModeSetRequest))] [JsonSerializable(typeof(SessionModeSetResult))] +[JsonSerializable(typeof(SessionModelGetCurrentRequest))] [JsonSerializable(typeof(SessionModelGetCurrentResult))] +[JsonSerializable(typeof(SessionModelSwitchToRequest))] [JsonSerializable(typeof(SessionModelSwitchToResult))] +[JsonSerializable(typeof(SessionPermissionsHandlePendingPermissionRequestRequest))] +[JsonSerializable(typeof(SessionPermissionsHandlePendingPermissionRequestResult))] +[JsonSerializable(typeof(SessionPlanDeleteRequest))] [JsonSerializable(typeof(SessionPlanDeleteResult))] +[JsonSerializable(typeof(SessionPlanReadRequest))] [JsonSerializable(typeof(SessionPlanReadResult))] +[JsonSerializable(typeof(SessionPlanUpdateRequest))] [JsonSerializable(typeof(SessionPlanUpdateResult))] +[JsonSerializable(typeof(SessionToolsHandlePendingToolCallRequest))] +[JsonSerializable(typeof(SessionToolsHandlePendingToolCallResult))] +[JsonSerializable(typeof(SessionWorkspaceCreateFileRequest))] [JsonSerializable(typeof(SessionWorkspaceCreateFileResult))] +[JsonSerializable(typeof(SessionWorkspaceListFilesRequest))] [JsonSerializable(typeof(SessionWorkspaceListFilesResult))] +[JsonSerializable(typeof(SessionWorkspaceReadFileRequest))] [JsonSerializable(typeof(SessionWorkspaceReadFileResult))] -[JsonSerializable(typeof(SetRequest))] -[JsonSerializable(typeof(StartRequest))] -[JsonSerializable(typeof(SwitchToRequest))] [JsonSerializable(typeof(Tool))] +[JsonSerializable(typeof(ToolsListRequest))] [JsonSerializable(typeof(ToolsListResult))] -[JsonSerializable(typeof(UpdateRequest))] internal partial class RpcJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index c2549803a..c497038c6 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -5,6 +5,9 @@ // AUTO-GENERATED FILE - DO NOT EDIT // Generated from: session-events.schema.json +// Generated code does not have XML doc comments; suppress CS1591 to avoid warnings. +#pragma warning disable CS1591 + using System.Text.Json; using System.Text.Json.Serialization; @@ -22,12 +25,23 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(AssistantMessageDeltaEvent), "assistant.message_delta")] [JsonDerivedType(typeof(AssistantReasoningEvent), "assistant.reasoning")] [JsonDerivedType(typeof(AssistantReasoningDeltaEvent), "assistant.reasoning_delta")] +[JsonDerivedType(typeof(AssistantStreamingDeltaEvent), "assistant.streaming_delta")] [JsonDerivedType(typeof(AssistantTurnEndEvent), "assistant.turn_end")] [JsonDerivedType(typeof(AssistantTurnStartEvent), "assistant.turn_start")] [JsonDerivedType(typeof(AssistantUsageEvent), "assistant.usage")] +[JsonDerivedType(typeof(CommandCompletedEvent), "command.completed")] +[JsonDerivedType(typeof(CommandQueuedEvent), "command.queued")] +[JsonDerivedType(typeof(ElicitationCompletedEvent), "elicitation.completed")] +[JsonDerivedType(typeof(ElicitationRequestedEvent), "elicitation.requested")] +[JsonDerivedType(typeof(ExitPlanModeCompletedEvent), "exit_plan_mode.completed")] +[JsonDerivedType(typeof(ExitPlanModeRequestedEvent), "exit_plan_mode.requested")] +[JsonDerivedType(typeof(ExternalToolCompletedEvent), "external_tool.completed")] +[JsonDerivedType(typeof(ExternalToolRequestedEvent), "external_tool.requested")] [JsonDerivedType(typeof(HookEndEvent), "hook.end")] [JsonDerivedType(typeof(HookStartEvent), "hook.start")] [JsonDerivedType(typeof(PendingMessagesModifiedEvent), "pending_messages.modified")] +[JsonDerivedType(typeof(PermissionCompletedEvent), "permission.completed")] +[JsonDerivedType(typeof(PermissionRequestedEvent), "permission.requested")] [JsonDerivedType(typeof(SessionCompactionCompleteEvent), "session.compaction_complete")] [JsonDerivedType(typeof(SessionCompactionStartEvent), "session.compaction_start")] [JsonDerivedType(typeof(SessionContextChangedEvent), "session.context_changed")] @@ -42,6 +56,7 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(SessionShutdownEvent), "session.shutdown")] [JsonDerivedType(typeof(SessionSnapshotRewindEvent), "session.snapshot_rewind")] [JsonDerivedType(typeof(SessionStartEvent), "session.start")] +[JsonDerivedType(typeof(SessionTaskCompleteEvent), "session.task_complete")] [JsonDerivedType(typeof(SessionTitleChangedEvent), "session.title_changed")] [JsonDerivedType(typeof(SessionTruncationEvent), "session.truncation")] [JsonDerivedType(typeof(SessionUsageInfoEvent), "session.usage_info")] @@ -49,6 +64,7 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(SessionWorkspaceFileChangedEvent), "session.workspace_file_changed")] [JsonDerivedType(typeof(SkillInvokedEvent), "skill.invoked")] [JsonDerivedType(typeof(SubagentCompletedEvent), "subagent.completed")] +[JsonDerivedType(typeof(SubagentDeselectedEvent), "subagent.deselected")] [JsonDerivedType(typeof(SubagentFailedEvent), "subagent.failed")] [JsonDerivedType(typeof(SubagentSelectedEvent), "subagent.selected")] [JsonDerivedType(typeof(SubagentStartedEvent), "subagent.started")] @@ -58,6 +74,8 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(ToolExecutionProgressEvent), "tool.execution_progress")] [JsonDerivedType(typeof(ToolExecutionStartEvent), "tool.execution_start")] [JsonDerivedType(typeof(ToolUserRequestedEvent), "tool.user_requested")] +[JsonDerivedType(typeof(UserInputCompletedEvent), "user_input.completed")] +[JsonDerivedType(typeof(UserInputRequestedEvent), "user_input.requested")] [JsonDerivedType(typeof(UserMessageEvent), "user.message")] public abstract partial class SessionEvent { @@ -315,6 +333,18 @@ public partial class SessionCompactionCompleteEvent : SessionEvent public required SessionCompactionCompleteData Data { get; set; } } +/// +/// Event: session.task_complete +/// +public partial class SessionTaskCompleteEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "session.task_complete"; + + [JsonPropertyName("data")] + public required SessionTaskCompleteData Data { get; set; } +} + /// /// Event: user.message /// @@ -387,6 +417,18 @@ public partial class AssistantReasoningDeltaEvent : SessionEvent public required AssistantReasoningDeltaData Data { get; set; } } +/// +/// Event: assistant.streaming_delta +/// +public partial class AssistantStreamingDeltaEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "assistant.streaming_delta"; + + [JsonPropertyName("data")] + public required AssistantStreamingDeltaData Data { get; set; } +} + /// /// Event: assistant.message /// @@ -567,6 +609,18 @@ public partial class SubagentSelectedEvent : SessionEvent public required SubagentSelectedData Data { get; set; } } +/// +/// Event: subagent.deselected +/// +public partial class SubagentDeselectedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "subagent.deselected"; + + [JsonPropertyName("data")] + public required SubagentDeselectedData Data { get; set; } +} + /// /// Event: hook.start /// @@ -603,6 +657,150 @@ public partial class SystemMessageEvent : SessionEvent public required SystemMessageData Data { get; set; } } +/// +/// Event: permission.requested +/// +public partial class PermissionRequestedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "permission.requested"; + + [JsonPropertyName("data")] + public required PermissionRequestedData Data { get; set; } +} + +/// +/// Event: permission.completed +/// +public partial class PermissionCompletedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "permission.completed"; + + [JsonPropertyName("data")] + public required PermissionCompletedData Data { get; set; } +} + +/// +/// Event: user_input.requested +/// +public partial class UserInputRequestedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "user_input.requested"; + + [JsonPropertyName("data")] + public required UserInputRequestedData Data { get; set; } +} + +/// +/// Event: user_input.completed +/// +public partial class UserInputCompletedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "user_input.completed"; + + [JsonPropertyName("data")] + public required UserInputCompletedData Data { get; set; } +} + +/// +/// Event: elicitation.requested +/// +public partial class ElicitationRequestedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "elicitation.requested"; + + [JsonPropertyName("data")] + public required ElicitationRequestedData Data { get; set; } +} + +/// +/// Event: elicitation.completed +/// +public partial class ElicitationCompletedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "elicitation.completed"; + + [JsonPropertyName("data")] + public required ElicitationCompletedData Data { get; set; } +} + +/// +/// Event: external_tool.requested +/// +public partial class ExternalToolRequestedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "external_tool.requested"; + + [JsonPropertyName("data")] + public required ExternalToolRequestedData Data { get; set; } +} + +/// +/// Event: external_tool.completed +/// +public partial class ExternalToolCompletedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "external_tool.completed"; + + [JsonPropertyName("data")] + public required ExternalToolCompletedData Data { get; set; } +} + +/// +/// Event: command.queued +/// +public partial class CommandQueuedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "command.queued"; + + [JsonPropertyName("data")] + public required CommandQueuedData Data { get; set; } +} + +/// +/// Event: command.completed +/// +public partial class CommandCompletedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "command.completed"; + + [JsonPropertyName("data")] + public required CommandCompletedData Data { get; set; } +} + +/// +/// Event: exit_plan_mode.requested +/// +public partial class ExitPlanModeRequestedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "exit_plan_mode.requested"; + + [JsonPropertyName("data")] + public required ExitPlanModeRequestedData Data { get; set; } +} + +/// +/// Event: exit_plan_mode.completed +/// +public partial class ExitPlanModeCompletedEvent : SessionEvent +{ + [JsonIgnore] + public override string Type => "exit_plan_mode.completed"; + + [JsonPropertyName("data")] + public required ExitPlanModeCompletedData Data { get; set; } +} + public partial class SessionStartData { [JsonPropertyName("sessionId")] @@ -665,6 +863,9 @@ public partial class SessionErrorData public partial class SessionIdleData { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("backgroundTasks")] + public SessionIdleDataBackgroundTasks? BackgroundTasks { get; set; } } public partial class SessionTitleChangedData @@ -899,6 +1100,13 @@ public partial class SessionCompactionCompleteData public string? RequestId { get; set; } } +public partial class SessionTaskCompleteData +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("summary")] + public string? Summary { get; set; } +} + public partial class UserMessageData { [JsonPropertyName("content")] @@ -919,6 +1127,10 @@ public partial class UserMessageData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("agentMode")] public UserMessageDataAgentMode? AgentMode { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("interactionId")] + public string? InteractionId { get; set; } } public partial class PendingMessagesModifiedData @@ -929,6 +1141,10 @@ public partial class AssistantTurnStartData { [JsonPropertyName("turnId")] public required string TurnId { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("interactionId")] + public string? InteractionId { get; set; } } public partial class AssistantIntentData @@ -955,6 +1171,12 @@ public partial class AssistantReasoningDeltaData public required string DeltaContent { get; set; } } +public partial class AssistantStreamingDeltaData +{ + [JsonPropertyName("totalResponseSizeBytes")] + public required double TotalResponseSizeBytes { get; set; } +} + public partial class AssistantMessageData { [JsonPropertyName("messageId")] @@ -983,6 +1205,14 @@ public partial class AssistantMessageData [JsonPropertyName("phase")] public string? Phase { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("outputTokens")] + public double? OutputTokens { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("interactionId")] + public string? InteractionId { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("parentToolCallId")] public string? ParentToolCallId { get; set; } @@ -996,10 +1226,6 @@ public partial class AssistantMessageDeltaData [JsonPropertyName("deltaContent")] public required string DeltaContent { get; set; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("totalResponseSizeBytes")] - public double? TotalResponseSizeBytes { get; set; } - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("parentToolCallId")] public string? ParentToolCallId { get; set; } @@ -1059,6 +1285,10 @@ public partial class AssistantUsageData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("quotaSnapshots")] public Dictionary? QuotaSnapshots { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("copilotUsage")] + public AssistantUsageDataCopilotUsage? CopilotUsage { get; set; } } public partial class AbortData @@ -1131,6 +1361,14 @@ public partial class ToolExecutionCompleteData [JsonPropertyName("success")] public required bool Success { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("model")] + public string? Model { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("interactionId")] + public string? InteractionId { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("isUserRequested")] public bool? IsUserRequested { get; set; } @@ -1166,6 +1404,14 @@ public partial class SkillInvokedData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("allowedTools")] public string[]? AllowedTools { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pluginName")] + public string? PluginName { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("pluginVersion")] + public string? PluginVersion { get; set; } } public partial class SubagentStartedData @@ -1222,6 +1468,10 @@ public partial class SubagentSelectedData public string[]? Tools { get; set; } } +public partial class SubagentDeselectedData +{ +} + public partial class HookStartData { [JsonPropertyName("hookInvocationId")] @@ -1272,6 +1522,133 @@ public partial class SystemMessageData public SystemMessageDataMetadata? Metadata { get; set; } } +public partial class PermissionRequestedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } + + [JsonPropertyName("permissionRequest")] + public required object PermissionRequest { get; set; } +} + +public partial class PermissionCompletedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } + + [JsonPropertyName("result")] + public required PermissionCompletedDataResult Result { get; set; } +} + +public partial class UserInputRequestedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } + + [JsonPropertyName("question")] + public required string Question { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("choices")] + public string[]? Choices { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("allowFreeform")] + public bool? AllowFreeform { get; set; } +} + +public partial class UserInputCompletedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } +} + +public partial class ElicitationRequestedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } + + [JsonPropertyName("message")] + public required string Message { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("mode")] + public string? Mode { get; set; } + + [JsonPropertyName("requestedSchema")] + public required ElicitationRequestedDataRequestedSchema RequestedSchema { get; set; } +} + +public partial class ElicitationCompletedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } +} + +public partial class ExternalToolRequestedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } + + [JsonPropertyName("sessionId")] + public required string SessionId { get; set; } + + [JsonPropertyName("toolCallId")] + public required string ToolCallId { get; set; } + + [JsonPropertyName("toolName")] + public required string ToolName { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("arguments")] + public object? Arguments { get; set; } +} + +public partial class ExternalToolCompletedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } +} + +public partial class CommandQueuedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } + + [JsonPropertyName("command")] + public required string Command { get; set; } +} + +public partial class CommandCompletedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } +} + +public partial class ExitPlanModeRequestedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } + + [JsonPropertyName("summary")] + public required string Summary { get; set; } + + [JsonPropertyName("planContent")] + public required string PlanContent { get; set; } + + [JsonPropertyName("actions")] + public required string[] Actions { get; set; } + + [JsonPropertyName("recommendedAction")] + public required string RecommendedAction { get; set; } +} + +public partial class ExitPlanModeCompletedData +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; set; } +} + public partial class SessionStartDataContext { [JsonPropertyName("cwd")] @@ -1308,6 +1685,38 @@ public partial class SessionResumeDataContext public string? Branch { get; set; } } +public partial class SessionIdleDataBackgroundTasksAgentsItem +{ + [JsonPropertyName("agentId")] + public required string AgentId { get; set; } + + [JsonPropertyName("agentType")] + public required string AgentType { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("description")] + public string? Description { get; set; } +} + +public partial class SessionIdleDataBackgroundTasksShellsItem +{ + [JsonPropertyName("shellId")] + public required string ShellId { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("description")] + public string? Description { get; set; } +} + +public partial class SessionIdleDataBackgroundTasks +{ + [JsonPropertyName("agents")] + public required SessionIdleDataBackgroundTasksAgentsItem[] Agents { get; set; } + + [JsonPropertyName("shells")] + public required SessionIdleDataBackgroundTasksShellsItem[] Shells { get; set; } +} + public partial class SessionHandoffDataRepository { [JsonPropertyName("owner")] @@ -1440,12 +1849,34 @@ public partial class UserMessageDataAttachmentsItemSelection : UserMessageDataAt public required UserMessageDataAttachmentsItemSelectionSelection Selection { get; set; } } +public partial class UserMessageDataAttachmentsItemGithubReference : UserMessageDataAttachmentsItem +{ + [JsonIgnore] + public override string Type => "github_reference"; + + [JsonPropertyName("number")] + public required double Number { get; set; } + + [JsonPropertyName("title")] + public required string Title { get; set; } + + [JsonPropertyName("referenceType")] + public required UserMessageDataAttachmentsItemGithubReferenceReferenceType ReferenceType { get; set; } + + [JsonPropertyName("state")] + public required string State { get; set; } + + [JsonPropertyName("url")] + public required string Url { get; set; } +} + [JsonPolymorphic( TypeDiscriminatorPropertyName = "type", UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType)] [JsonDerivedType(typeof(UserMessageDataAttachmentsItemFile), "file")] [JsonDerivedType(typeof(UserMessageDataAttachmentsItemDirectory), "directory")] [JsonDerivedType(typeof(UserMessageDataAttachmentsItemSelection), "selection")] +[JsonDerivedType(typeof(UserMessageDataAttachmentsItemGithubReference), "github_reference")] public partial class UserMessageDataAttachmentsItem { [JsonPropertyName("type")] @@ -1470,6 +1901,30 @@ public partial class AssistantMessageDataToolRequestsItem public AssistantMessageDataToolRequestsItemType? Type { get; set; } } +public partial class AssistantUsageDataCopilotUsageTokenDetailsItem +{ + [JsonPropertyName("batchSize")] + public required double BatchSize { get; set; } + + [JsonPropertyName("costPerBatch")] + public required double CostPerBatch { get; set; } + + [JsonPropertyName("tokenCount")] + public required double TokenCount { get; set; } + + [JsonPropertyName("tokenType")] + public required string TokenType { get; set; } +} + +public partial class AssistantUsageDataCopilotUsage +{ + [JsonPropertyName("tokenDetails")] + public required AssistantUsageDataCopilotUsageTokenDetailsItem[] TokenDetails { get; set; } + + [JsonPropertyName("totalNanoAiu")] + public required double TotalNanoAiu { get; set; } +} + public partial class ToolExecutionCompleteDataResultContentsItemText : ToolExecutionCompleteDataResultContentsItem { [JsonIgnore] @@ -1640,6 +2095,25 @@ public partial class SystemMessageDataMetadata public Dictionary? Variables { get; set; } } +public partial class PermissionCompletedDataResult +{ + [JsonPropertyName("kind")] + public required PermissionCompletedDataResultKind Kind { get; set; } +} + +public partial class ElicitationRequestedDataRequestedSchema +{ + [JsonPropertyName("type")] + public required string Type { get; set; } + + [JsonPropertyName("properties")] + public required Dictionary Properties { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("required")] + public string[]? Required { get; set; } +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum SessionPlanChangedDataOperation { @@ -1678,6 +2152,17 @@ public enum SessionShutdownDataShutdownType Error, } +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum UserMessageDataAttachmentsItemGithubReferenceReferenceType +{ + [JsonStringEnumMemberName("issue")] + Issue, + [JsonStringEnumMemberName("pr")] + Pr, + [JsonStringEnumMemberName("discussion")] + Discussion, +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum UserMessageDataAgentMode { @@ -1718,6 +2203,21 @@ public enum SystemMessageDataRole Developer, } +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PermissionCompletedDataResultKind +{ + [JsonStringEnumMemberName("approved")] + Approved, + [JsonStringEnumMemberName("denied-by-rules")] + DeniedByRules, + [JsonStringEnumMemberName("denied-no-approval-rule-and-could-not-request-from-user")] + DeniedNoApprovalRuleAndCouldNotRequestFromUser, + [JsonStringEnumMemberName("denied-interactively-by-user")] + DeniedInteractivelyByUser, + [JsonStringEnumMemberName("denied-by-content-exclusion-policy")] + DeniedByContentExclusionPolicy, +} + [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, @@ -1736,12 +2236,33 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(AssistantReasoningDeltaData))] [JsonSerializable(typeof(AssistantReasoningDeltaEvent))] [JsonSerializable(typeof(AssistantReasoningEvent))] +[JsonSerializable(typeof(AssistantStreamingDeltaData))] +[JsonSerializable(typeof(AssistantStreamingDeltaEvent))] [JsonSerializable(typeof(AssistantTurnEndData))] [JsonSerializable(typeof(AssistantTurnEndEvent))] [JsonSerializable(typeof(AssistantTurnStartData))] [JsonSerializable(typeof(AssistantTurnStartEvent))] [JsonSerializable(typeof(AssistantUsageData))] +[JsonSerializable(typeof(AssistantUsageDataCopilotUsage))] +[JsonSerializable(typeof(AssistantUsageDataCopilotUsageTokenDetailsItem))] [JsonSerializable(typeof(AssistantUsageEvent))] +[JsonSerializable(typeof(CommandCompletedData))] +[JsonSerializable(typeof(CommandCompletedEvent))] +[JsonSerializable(typeof(CommandQueuedData))] +[JsonSerializable(typeof(CommandQueuedEvent))] +[JsonSerializable(typeof(ElicitationCompletedData))] +[JsonSerializable(typeof(ElicitationCompletedEvent))] +[JsonSerializable(typeof(ElicitationRequestedData))] +[JsonSerializable(typeof(ElicitationRequestedDataRequestedSchema))] +[JsonSerializable(typeof(ElicitationRequestedEvent))] +[JsonSerializable(typeof(ExitPlanModeCompletedData))] +[JsonSerializable(typeof(ExitPlanModeCompletedEvent))] +[JsonSerializable(typeof(ExitPlanModeRequestedData))] +[JsonSerializable(typeof(ExitPlanModeRequestedEvent))] +[JsonSerializable(typeof(ExternalToolCompletedData))] +[JsonSerializable(typeof(ExternalToolCompletedEvent))] +[JsonSerializable(typeof(ExternalToolRequestedData))] +[JsonSerializable(typeof(ExternalToolRequestedEvent))] [JsonSerializable(typeof(HookEndData))] [JsonSerializable(typeof(HookEndDataError))] [JsonSerializable(typeof(HookEndEvent))] @@ -1749,6 +2270,11 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(HookStartEvent))] [JsonSerializable(typeof(PendingMessagesModifiedData))] [JsonSerializable(typeof(PendingMessagesModifiedEvent))] +[JsonSerializable(typeof(PermissionCompletedData))] +[JsonSerializable(typeof(PermissionCompletedDataResult))] +[JsonSerializable(typeof(PermissionCompletedEvent))] +[JsonSerializable(typeof(PermissionRequestedData))] +[JsonSerializable(typeof(PermissionRequestedEvent))] [JsonSerializable(typeof(SessionCompactionCompleteData))] [JsonSerializable(typeof(SessionCompactionCompleteDataCompactionTokensUsed))] [JsonSerializable(typeof(SessionCompactionCompleteEvent))] @@ -1763,6 +2289,9 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(SessionHandoffDataRepository))] [JsonSerializable(typeof(SessionHandoffEvent))] [JsonSerializable(typeof(SessionIdleData))] +[JsonSerializable(typeof(SessionIdleDataBackgroundTasks))] +[JsonSerializable(typeof(SessionIdleDataBackgroundTasksAgentsItem))] +[JsonSerializable(typeof(SessionIdleDataBackgroundTasksShellsItem))] [JsonSerializable(typeof(SessionIdleEvent))] [JsonSerializable(typeof(SessionInfoData))] [JsonSerializable(typeof(SessionInfoEvent))] @@ -1783,6 +2312,8 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(SessionStartData))] [JsonSerializable(typeof(SessionStartDataContext))] [JsonSerializable(typeof(SessionStartEvent))] +[JsonSerializable(typeof(SessionTaskCompleteData))] +[JsonSerializable(typeof(SessionTaskCompleteEvent))] [JsonSerializable(typeof(SessionTitleChangedData))] [JsonSerializable(typeof(SessionTitleChangedEvent))] [JsonSerializable(typeof(SessionTruncationData))] @@ -1797,6 +2328,8 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(SkillInvokedEvent))] [JsonSerializable(typeof(SubagentCompletedData))] [JsonSerializable(typeof(SubagentCompletedEvent))] +[JsonSerializable(typeof(SubagentDeselectedData))] +[JsonSerializable(typeof(SubagentDeselectedEvent))] [JsonSerializable(typeof(SubagentFailedData))] [JsonSerializable(typeof(SubagentFailedEvent))] [JsonSerializable(typeof(SubagentSelectedData))] @@ -1826,12 +2359,17 @@ public enum SystemMessageDataRole [JsonSerializable(typeof(ToolExecutionStartEvent))] [JsonSerializable(typeof(ToolUserRequestedData))] [JsonSerializable(typeof(ToolUserRequestedEvent))] +[JsonSerializable(typeof(UserInputCompletedData))] +[JsonSerializable(typeof(UserInputCompletedEvent))] +[JsonSerializable(typeof(UserInputRequestedData))] +[JsonSerializable(typeof(UserInputRequestedEvent))] [JsonSerializable(typeof(UserMessageData))] [JsonSerializable(typeof(UserMessageDataAttachmentsItem))] [JsonSerializable(typeof(UserMessageDataAttachmentsItemDirectory))] [JsonSerializable(typeof(UserMessageDataAttachmentsItemDirectoryLineRange))] [JsonSerializable(typeof(UserMessageDataAttachmentsItemFile))] [JsonSerializable(typeof(UserMessageDataAttachmentsItemFileLineRange))] +[JsonSerializable(typeof(UserMessageDataAttachmentsItemGithubReference))] [JsonSerializable(typeof(UserMessageDataAttachmentsItemSelection))] [JsonSerializable(typeof(UserMessageDataAttachmentsItemSelectionSelection))] [JsonSerializable(typeof(UserMessageDataAttachmentsItemSelectionSelectionEnd))] diff --git a/dotnet/src/GitHub.Copilot.SDK.csproj b/dotnet/src/GitHub.Copilot.SDK.csproj index 019788cfa..8ae53ca74 100644 --- a/dotnet/src/GitHub.Copilot.SDK.csproj +++ b/dotnet/src/GitHub.Copilot.SDK.csproj @@ -1,20 +1,26 @@  - net8.0 - enable - enable - true + true 0.1.0 SDK for programmatic control of GitHub Copilot CLI GitHub GitHub Copyright (c) Microsoft Corporation. All rights reserved. MIT + https://github.com/github/copilot-sdk README.md https://github.com/github/copilot-sdk github;copilot;sdk;jsonrpc;agent true + true + snupkg + true + true + + + + true @@ -22,10 +28,11 @@ - - - - + + + + + diff --git a/dotnet/src/PermissionHandlers.cs b/dotnet/src/PermissionHandlers.cs index 22e5bdb17..3a40e7244 100644 --- a/dotnet/src/PermissionHandlers.cs +++ b/dotnet/src/PermissionHandlers.cs @@ -9,5 +9,5 @@ public static class PermissionHandler { /// A that approves all permission requests. public static PermissionRequestHandler ApproveAll { get; } = - (_, _) => Task.FromResult(new PermissionRequestResult { Kind = "approved" }); + (_, _) => Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }); } diff --git a/dotnet/src/SdkProtocolVersion.cs b/dotnet/src/SdkProtocolVersion.cs index bb47dfebf..f3d8f04c5 100644 --- a/dotnet/src/SdkProtocolVersion.cs +++ b/dotnet/src/SdkProtocolVersion.cs @@ -11,10 +11,13 @@ internal static class SdkProtocolVersion /// /// The SDK protocol version. /// - public const int Version = 2; + private const int Version = 3; /// /// Gets the SDK protocol version. /// - public static int GetVersion() => Version; + public static int GetVersion() + { + return Version; + } } diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 4feeb9f95..397eae0fa 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -24,10 +24,18 @@ namespace GitHub.Copilot.SDK; /// The session provides methods to send messages, subscribe to events, retrieve /// conversation history, and manage the session lifecycle. /// +/// +/// implements . Use the +/// await using pattern for automatic cleanup, or call +/// explicitly. Disposing a session releases in-memory resources but preserves session data +/// on disk — the conversation can be resumed later via +/// . To permanently delete session data, +/// use . +/// /// /// /// -/// await using var session = await client.CreateSessionAsync(new SessionConfig { Model = "gpt-4" }); +/// await using var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll, Model = "gpt-4" }); /// /// // Subscribe to events /// using var subscription = session.On(evt => @@ -42,15 +50,19 @@ namespace GitHub.Copilot.SDK; /// await session.SendAndWaitAsync(new MessageOptions { Prompt = "Hello, world!" }); /// /// -public partial class CopilotSession : IAsyncDisposable +public sealed partial class CopilotSession : IAsyncDisposable { - private readonly HashSet _eventHandlers = new(); - private readonly Dictionary _toolHandlers = new(); + /// + /// Multicast delegate used as a thread-safe, insertion-ordered handler list. + /// The compiler-generated add/remove accessors use a lock-free CAS loop over the backing field. + /// Dispatch reads the field once (inherent snapshot, no allocation). + /// Expected handler count is small (typically 1–3), so Delegate.Combine/Remove cost is negligible. + /// + private event SessionEventHandler? EventHandlers; + private readonly Dictionary _toolHandlers = []; private readonly JsonRpc _rpc; - private PermissionRequestHandler? _permissionHandler; - private readonly SemaphoreSlim _permissionHandlerLock = new(1, 1); - private UserInputHandler? _userInputHandler; - private readonly SemaphoreSlim _userInputHandlerLock = new(1, 1); + private volatile PermissionRequestHandler? _permissionHandler; + private volatile UserInputHandler? _userInputHandler; private SessionHooks? _hooks; private readonly SemaphoreSlim _hooksLock = new(1, 1); private SessionRpc? _sessionRpc; @@ -92,8 +104,10 @@ internal CopilotSession(string sessionId, JsonRpc rpc, string? workspacePath = n WorkspacePath = workspacePath; } - private Task InvokeRpcAsync(string method, object?[]? args, CancellationToken cancellationToken) => - CopilotClient.InvokeRpcAsync(_rpc, method, args, cancellationToken); + private Task InvokeRpcAsync(string method, object?[]? args, CancellationToken cancellationToken) + { + return CopilotClient.InvokeRpcAsync(_rpc, method, args, cancellationToken); + } /// /// Sends a message to the Copilot session and waits for the response. @@ -147,6 +161,7 @@ public async Task SendAsync(MessageOptions options, CancellationToken ca /// A that can be used to cancel the operation. /// A task that resolves with the final assistant message event, or null if none was received. /// Thrown if the timeout is reached before the session becomes idle. + /// Thrown if the is cancelled. /// Thrown if the session has been disposed. /// /// @@ -201,7 +216,12 @@ void Handler(SessionEvent evt) cts.CancelAfter(effectiveTimeout); using var registration = cts.Token.Register(() => - tcs.TrySetException(new TimeoutException($"SendAndWaitAsync timed out after {effectiveTimeout}"))); + { + if (cancellationToken.IsCancellationRequested) + tcs.TrySetCanceled(cancellationToken); + else + tcs.TrySetException(new TimeoutException($"SendAndWaitAsync timed out after {effectiveTimeout}")); + }); return await tcs.Task; } @@ -239,8 +259,8 @@ void Handler(SessionEvent evt) /// public IDisposable On(SessionEventHandler handler) { - _eventHandlers.Add(handler); - return new OnDisposeCall(() => _eventHandlers.Remove(handler)); + EventHandlers += handler; + return new ActionDisposable(() => EventHandlers -= handler); } /// @@ -249,14 +269,17 @@ public IDisposable On(SessionEventHandler handler) /// The session event to dispatch. /// /// This method is internal. Handler exceptions are allowed to propagate so they are not lost. + /// Broadcast request events (external_tool.requested, permission.requested) are handled + /// internally before being forwarded to user handlers. /// internal void DispatchEvent(SessionEvent sessionEvent) { - foreach (var handler in _eventHandlers.ToArray()) - { - // We allow handler exceptions to propagate so they are not lost - handler(sessionEvent); - } + // Handle broadcast request events (protocol v3) before dispatching to user handlers. + // Fire-and-forget: the response is sent asynchronously via RPC. + HandleBroadcastEventAsync(sessionEvent); + + // Reading the field once gives us a snapshot; delegates are immutable. + EventHandlers?.Invoke(sessionEvent); } /// @@ -281,8 +304,10 @@ internal void RegisterTools(ICollection tools) /// /// The name of the tool to retrieve. /// The tool if found; otherwise, null. - internal AIFunction? GetTool(string name) => - _toolHandlers.TryGetValue(name, out var tool) ? tool : null; + internal AIFunction? GetTool(string name) + { + return _toolHandlers.TryGetValue(name, out var tool) ? tool : null; + } /// /// Registers a handler for permission requests. @@ -294,15 +319,7 @@ internal void RegisterTools(ICollection tools) /// internal void RegisterPermissionHandler(PermissionRequestHandler handler) { - _permissionHandlerLock.Wait(); - try - { - _permissionHandler = handler; - } - finally - { - _permissionHandlerLock.Release(); - } + _permissionHandler = handler; } /// @@ -312,22 +329,13 @@ internal void RegisterPermissionHandler(PermissionRequestHandler handler) /// A task that resolves with the permission decision. internal async Task HandlePermissionRequestAsync(JsonElement permissionRequestData) { - await _permissionHandlerLock.WaitAsync(); - PermissionRequestHandler? handler; - try - { - handler = _permissionHandler; - } - finally - { - _permissionHandlerLock.Release(); - } + var handler = _permissionHandler; if (handler == null) { return new PermissionRequestResult { - Kind = "denied-no-approval-rule-and-could-not-request-from-user" + Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser }; } @@ -343,45 +351,172 @@ internal async Task HandlePermissionRequestAsync(JsonEl } /// - /// Registers a handler for user input requests from the agent. + /// Handles broadcast request events by executing local handlers and responding via RPC. + /// Implements the protocol v3 broadcast model where tool calls and permission requests + /// are broadcast as session events to all clients. /// - /// The handler to invoke when user input is requested. - internal void RegisterUserInputHandler(UserInputHandler handler) + private async void HandleBroadcastEventAsync(SessionEvent sessionEvent) + { + switch (sessionEvent) + { + case ExternalToolRequestedEvent toolEvent: + { + var data = toolEvent.Data; + if (string.IsNullOrEmpty(data.RequestId) || string.IsNullOrEmpty(data.ToolName)) + return; + + var tool = GetTool(data.ToolName); + if (tool is null) + return; // This client doesn't handle this tool; another client will. + + await ExecuteToolAndRespondAsync(data.RequestId, data.ToolName, data.ToolCallId, data.Arguments, tool); + break; + } + + case PermissionRequestedEvent permEvent: + { + var data = permEvent.Data; + if (string.IsNullOrEmpty(data.RequestId) || data.PermissionRequest is null) + return; + + var handler = _permissionHandler; + if (handler is null) + return; // This client doesn't handle permissions; another client will. + + await ExecutePermissionAndRespondAsync(data.RequestId, data.PermissionRequest, handler); + break; + } + } + } + + /// + /// Executes a tool handler and sends the result back via the HandlePendingToolCall RPC. + /// + private async Task ExecuteToolAndRespondAsync(string requestId, string toolName, string toolCallId, object? arguments, AIFunction tool) { - _userInputHandlerLock.Wait(); try { - _userInputHandler = handler; + var invocation = new ToolInvocation + { + SessionId = SessionId, + ToolCallId = toolCallId, + ToolName = toolName, + Arguments = arguments + }; + + var aiFunctionArgs = new AIFunctionArguments + { + Context = new Dictionary + { + [typeof(ToolInvocation)] = invocation + } + }; + + if (arguments is not null) + { + if (arguments is not JsonElement incomingJsonArgs) + { + throw new InvalidOperationException($"Incoming arguments must be a {nameof(JsonElement)}; received {arguments.GetType().Name}"); + } + + foreach (var prop in incomingJsonArgs.EnumerateObject()) + { + aiFunctionArgs[prop.Name] = prop.Value; + } + } + + var result = await tool.InvokeAsync(aiFunctionArgs); + + var toolResultObject = result is ToolResultAIContent trac ? trac.Result : new ToolResultObject + { + ResultType = "success", + TextResultForLlm = result is JsonElement { ValueKind: JsonValueKind.String } je + ? je.GetString()! + : JsonSerializer.Serialize(result, tool.JsonSerializerOptions.GetTypeInfo(typeof(object))), + }; + + await Rpc.Tools.HandlePendingToolCallAsync(requestId, toolResultObject, error: null); } - finally + catch (Exception ex) { - _userInputHandlerLock.Release(); + try + { + await Rpc.Tools.HandlePendingToolCallAsync(requestId, result: null, error: ex.Message); + } + catch (IOException) + { + // Connection lost or RPC error — nothing we can do + } + catch (ObjectDisposedException) + { + // Connection already disposed — nothing we can do + } } } /// - /// Handles a user input request from the Copilot CLI. + /// Executes a permission handler and sends the result back via the HandlePendingPermissionRequest RPC. /// - /// The user input request from the CLI. - /// A task that resolves with the user's response. - internal async Task HandleUserInputRequestAsync(UserInputRequest request) + private async Task ExecutePermissionAndRespondAsync(string requestId, object permissionRequestData, PermissionRequestHandler handler) { - await _userInputHandlerLock.WaitAsync(); - UserInputHandler? handler; try { - handler = _userInputHandler; + // PermissionRequestedData.PermissionRequest is typed as `object` in generated code, + // but StreamJsonRpc deserializes it as a JsonElement. + if (permissionRequestData is not JsonElement permJsonElement) + { + throw new InvalidOperationException( + $"Permission request data must be a {nameof(JsonElement)}; received {permissionRequestData.GetType().Name}"); + } + + var request = JsonSerializer.Deserialize(permJsonElement.GetRawText(), SessionJsonContext.Default.PermissionRequest) + ?? throw new InvalidOperationException("Failed to deserialize permission request"); + + var invocation = new PermissionInvocation + { + SessionId = SessionId + }; + + var result = await handler(request, invocation); + await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, result); } - finally + catch (Exception) { - _userInputHandlerLock.Release(); + try + { + await Rpc.Permissions.HandlePendingPermissionRequestAsync(requestId, new PermissionRequestResult + { + Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser + }); + } + catch (IOException) + { + // Connection lost or RPC error — nothing we can do + } + catch (ObjectDisposedException) + { + // Connection already disposed — nothing we can do + } } + } - if (handler == null) - { - throw new InvalidOperationException("No user input handler registered"); - } + /// + /// Registers a handler for user input requests from the agent. + /// + /// The handler to invoke when user input is requested. + internal void RegisterUserInputHandler(UserInputHandler handler) + { + _userInputHandler = handler; + } + /// + /// Handles a user input request from the Copilot CLI. + /// + /// The user input request from the CLI. + /// A task that resolves with the user's response. + internal async Task HandleUserInputRequestAsync(UserInputRequest request) + { + var handler = _userInputHandler ?? throw new InvalidOperationException("No user input handler registered"); var invocation = new UserInputInvocation { SessionId = SessionId @@ -535,26 +670,45 @@ await InvokeRpcAsync( } /// - /// Disposes the and releases all associated resources. + /// Changes the model for this session. + /// The new model takes effect for the next message. Conversation history is preserved. + /// + /// Model ID to switch to (e.g., "gpt-4.1"). + /// Optional cancellation token. + /// + /// + /// await session.SetModelAsync("gpt-4.1"); + /// + /// + public async Task SetModelAsync(string model, CancellationToken cancellationToken = default) + { + await Rpc.Model.SwitchToAsync(model, cancellationToken); + } + + /// + /// Closes this session and releases all in-memory resources (event handlers, + /// tool handlers, permission handlers). /// /// A task representing the dispose operation. /// /// - /// After calling this method, the session can no longer be used. All event handlers - /// and tool handlers are cleared. + /// Session state on disk (conversation history, planning state, artifacts) is + /// preserved, so the conversation can be resumed later by calling + /// with the session ID. To + /// permanently remove all session data including files on disk, use + /// instead. /// /// - /// To continue the conversation, use - /// with the session ID. + /// After calling this method, the session object can no longer be used. /// /// /// /// - /// // Using 'await using' for automatic disposal - /// await using var session = await client.CreateSessionAsync(); + /// // Using 'await using' for automatic disposal — session can still be resumed later + /// await using var session = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// /// // Or manually dispose - /// var session2 = await client.CreateSessionAsync(); + /// var session2 = await client.CreateSessionAsync(new() { OnPermissionRequest = PermissionHandler.ApproveAll }); /// // ... use the session ... /// await session2.DisposeAsync(); /// @@ -580,23 +734,10 @@ await InvokeRpcAsync( // Connection is broken or closed } - _eventHandlers.Clear(); + EventHandlers = null; _toolHandlers.Clear(); - await _permissionHandlerLock.WaitAsync(); - try - { - _permissionHandler = null; - } - finally - { - _permissionHandlerLock.Release(); - } - } - - private class OnDisposeCall(Action callback) : IDisposable - { - public void Dispose() => callback(); + _permissionHandler = null; } internal record SendMessageRequest @@ -619,7 +760,7 @@ internal record GetMessagesRequest internal record GetMessagesResponse { - public List Events { get; init; } = new(); + public List Events { get; init; } = []; } internal record SessionAbortRequest diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index acf03b4d2..dbee05cfd 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2,6 +2,9 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; @@ -9,19 +12,29 @@ namespace GitHub.Copilot.SDK; -[JsonConverter(typeof(JsonStringEnumConverter))] +/// +/// Represents the connection state of the Copilot client. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] public enum ConnectionState { + /// The client is not connected to the server. [JsonStringEnumMemberName("disconnected")] Disconnected, + /// The client is establishing a connection to the server. [JsonStringEnumMemberName("connecting")] Connecting, + /// The client is connected and ready to communicate. [JsonStringEnumMemberName("connected")] Connected, + /// The connection is in an error state. [JsonStringEnumMemberName("error")] Error } +/// +/// Configuration options for creating a instance. +/// public class CopilotClientOptions { /// @@ -44,7 +57,7 @@ protected CopilotClientOptions(CopilotClientOptions? other) CliUrl = other.CliUrl; Cwd = other.Cwd; Environment = other.Environment; - GithubToken = other.GithubToken; + GitHubToken = other.GitHubToken; Logger = other.Logger; LogLevel = other.LogLevel; Port = other.Port; @@ -56,15 +69,45 @@ protected CopilotClientOptions(CopilotClientOptions? other) /// Path to the Copilot CLI executable. If not specified, uses the bundled CLI from the SDK. /// public string? CliPath { get; set; } + /// + /// Additional command-line arguments to pass to the CLI process. + /// public string[]? CliArgs { get; set; } + /// + /// Working directory for the CLI process. + /// public string? Cwd { get; set; } + /// + /// Port number for the CLI server when not using stdio transport. + /// public int Port { get; set; } + /// + /// Whether to use stdio transport for communication with the CLI server. + /// public bool UseStdio { get; set; } = true; + /// + /// URL of an existing CLI server to connect to instead of starting a new one. + /// public string? CliUrl { get; set; } + /// + /// Log level for the CLI server (e.g., "info", "debug", "warn", "error"). + /// public string LogLevel { get; set; } = "info"; + /// + /// Whether to automatically start the CLI server if it is not already running. + /// public bool AutoStart { get; set; } = true; + /// + /// Whether to automatically restart the CLI server if it exits unexpectedly. + /// public bool AutoRestart { get; set; } = true; + /// + /// Environment variables to pass to the CLI process. + /// public IReadOnlyDictionary? Environment { get; set; } + /// + /// Logger instance for SDK diagnostic output. + /// public ILogger? Logger { get; set; } /// @@ -72,13 +115,24 @@ protected CopilotClientOptions(CopilotClientOptions? other) /// When provided, the token is passed to the CLI server via environment variable. /// This takes priority over other authentication methods. /// - public string? GithubToken { get; set; } + public string? GitHubToken { get; set; } + + /// + /// Obsolete. Use instead. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("Use GitHubToken instead.", error: false)] + public string? GithubToken + { + get => GitHubToken; + set => GitHubToken = value; + } /// /// Whether to use the logged-in user for authentication. /// When true, the CLI server will attempt to use stored OAuth tokens or gh CLI auth. - /// When false, only explicit tokens (GithubToken or environment variables) are used. - /// Default: true (but defaults to false when GithubToken is provided). + /// When false, only explicit tokens (GitHubToken or environment variables) are used. + /// Default: true (but defaults to false when GitHubToken is provided). /// public bool? UseLoggedInUser { get; set; } @@ -91,81 +145,260 @@ protected CopilotClientOptions(CopilotClientOptions? other) /// Other reference-type properties (for example delegates and the logger) are not /// deep-cloned; the original and the clone will share those objects. /// - public virtual CopilotClientOptions Clone() => new(this); + public virtual CopilotClientOptions Clone() + { + return new(this); + } } +/// +/// Represents a binary result returned by a tool invocation. +/// public class ToolBinaryResult { + /// + /// Base64-encoded binary data. + /// [JsonPropertyName("data")] public string Data { get; set; } = string.Empty; + /// + /// MIME type of the binary data (e.g., "image/png"). + /// [JsonPropertyName("mimeType")] public string MimeType { get; set; } = string.Empty; + /// + /// Type identifier for the binary result. + /// [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; + /// + /// Optional human-readable description of the binary result. + /// [JsonPropertyName("description")] public string? Description { get; set; } } +/// +/// Represents the structured result of a tool execution. +/// public class ToolResultObject { + /// + /// Text result to be consumed by the language model. + /// [JsonPropertyName("textResultForLlm")] public string TextResultForLlm { get; set; } = string.Empty; + /// + /// Binary results (e.g., images) to be consumed by the language model. + /// [JsonPropertyName("binaryResultsForLlm")] public List? BinaryResultsForLlm { get; set; } + /// + /// Result type indicator. + /// + /// "success" — the tool executed successfully. + /// "failure" — the tool encountered an error. + /// "rejected" — the tool invocation was rejected. + /// "denied" — the tool invocation was denied by a permission check. + /// + /// [JsonPropertyName("resultType")] public string ResultType { get; set; } = "success"; + /// + /// Error message if the tool execution failed. + /// [JsonPropertyName("error")] public string? Error { get; set; } + /// + /// Log entry for the session history. + /// [JsonPropertyName("sessionLog")] public string? SessionLog { get; set; } + /// + /// Custom telemetry data associated with the tool execution. + /// [JsonPropertyName("toolTelemetry")] public Dictionary? ToolTelemetry { get; set; } } +/// +/// Contains context for a tool invocation callback. +/// public class ToolInvocation { + /// + /// Identifier of the session that triggered the tool call. + /// public string SessionId { get; set; } = string.Empty; + /// + /// Unique identifier of this specific tool call. + /// public string ToolCallId { get; set; } = string.Empty; + /// + /// Name of the tool being invoked. + /// public string ToolName { get; set; } = string.Empty; + /// + /// Arguments passed to the tool by the language model. + /// public object? Arguments { get; set; } } +/// +/// Delegate for handling tool invocations and returning a result. +/// public delegate Task ToolHandler(ToolInvocation invocation); +/// +/// Represents a permission request from the server for a tool operation. +/// public class PermissionRequest { + /// + /// Kind of permission being requested. + /// + /// "shell" — execute a shell command. + /// "write" — write to a file. + /// "read" — read a file. + /// "mcp" — invoke an MCP server tool. + /// "url" — access a URL. + /// "custom-tool" — invoke a custom tool. + /// + /// [JsonPropertyName("kind")] public string Kind { get; set; } = string.Empty; + /// + /// Identifier of the tool call that triggered the permission request. + /// [JsonPropertyName("toolCallId")] public string? ToolCallId { get; set; } + /// + /// Additional properties not explicitly modeled. + /// [JsonExtensionData] public Dictionary? ExtensionData { get; set; } } +/// Describes the kind of a permission request result. +[JsonConverter(typeof(PermissionRequestResultKind.Converter))] +[DebuggerDisplay("{Value,nq}")] +public readonly struct PermissionRequestResultKind : IEquatable +{ + /// Gets the kind indicating the permission was approved. + public static PermissionRequestResultKind Approved { get; } = new("approved"); + + /// Gets the kind indicating the permission was denied by rules. + public static PermissionRequestResultKind DeniedByRules { get; } = new("denied-by-rules"); + + /// Gets the kind indicating the permission was denied because no approval rule was found and the user could not be prompted. + public static PermissionRequestResultKind DeniedCouldNotRequestFromUser { get; } = new("denied-no-approval-rule-and-could-not-request-from-user"); + + /// Gets the kind indicating the permission was denied interactively by the user. + public static PermissionRequestResultKind DeniedInteractivelyByUser { get; } = new("denied-interactively-by-user"); + + /// Gets the underlying string value of this . + public string Value => _value ?? string.Empty; + + private readonly string? _value; + + /// Initializes a new instance of the struct. + /// The string value for this kind. + [JsonConstructor] + public PermissionRequestResultKind(string value) => _value = value; + + /// + public static bool operator ==(PermissionRequestResultKind left, PermissionRequestResultKind right) => left.Equals(right); + + /// + public static bool operator !=(PermissionRequestResultKind left, PermissionRequestResultKind right) => !left.Equals(right); + + /// + public override bool Equals([NotNullWhen(true)] object? obj) => obj is PermissionRequestResultKind other && Equals(other); + + /// + public bool Equals(PermissionRequestResultKind other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + /// + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class Converter : JsonConverter + { + /// + public override PermissionRequestResultKind Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Expected string for PermissionRequestResultKind."); + } + + var value = reader.GetString(); + if (value is null) + { + throw new JsonException("PermissionRequestResultKind value cannot be null."); + } + + return new PermissionRequestResultKind(value); + } + + /// + public override void Write(Utf8JsonWriter writer, PermissionRequestResultKind value, JsonSerializerOptions options) => + writer.WriteStringValue(value.Value); + } +} + +/// +/// Result of a permission request evaluation. +/// public class PermissionRequestResult { + /// + /// Permission decision kind. + /// + /// "approved" — the operation is allowed. + /// "denied-by-rules" — denied by configured permission rules. + /// "denied-interactively-by-user" — the user explicitly denied the request. + /// "denied-no-approval-rule-and-could-not-request-from-user" — no rule matched and user approval was unavailable. + /// + /// [JsonPropertyName("kind")] - public string Kind { get; set; } = string.Empty; + public PermissionRequestResultKind Kind { get; set; } + /// + /// Permission rules to apply for the decision. + /// [JsonPropertyName("rules")] public List? Rules { get; set; } } +/// +/// Contains context for a permission request callback. +/// public class PermissionInvocation { + /// + /// Identifier of the session that triggered the permission request. + /// public string SessionId { get; set; } = string.Empty; } +/// +/// Delegate for handling permission requests and returning a decision. +/// public delegate Task PermissionRequestHandler(PermissionRequest request, PermissionInvocation invocation); // ============================================================================ @@ -219,6 +452,9 @@ public class UserInputResponse /// public class UserInputInvocation { + /// + /// Identifier of the session that triggered the user input request. + /// public string SessionId { get; set; } = string.Empty; } @@ -236,6 +472,9 @@ public class UserInputInvocation /// public class HookInvocation { + /// + /// Identifier of the session that triggered the hook. + /// public string SessionId { get; set; } = string.Empty; } @@ -244,15 +483,27 @@ public class HookInvocation /// public class PreToolUseHookInput { + /// + /// Unix timestamp in milliseconds when the tool use was initiated. + /// [JsonPropertyName("timestamp")] public long Timestamp { get; set; } + /// + /// Current working directory of the session. + /// [JsonPropertyName("cwd")] public string Cwd { get; set; } = string.Empty; + /// + /// Name of the tool about to be executed. + /// [JsonPropertyName("toolName")] public string ToolName { get; set; } = string.Empty; + /// + /// Arguments that will be passed to the tool. + /// [JsonPropertyName("toolArgs")] public object? ToolArgs { get; set; } } @@ -263,24 +514,44 @@ public class PreToolUseHookInput public class PreToolUseHookOutput { /// - /// Permission decision: "allow", "deny", or "ask". + /// Permission decision for the pending tool call. + /// + /// "allow" — permit the tool to execute. + /// "deny" — block the tool from executing. + /// "ask" — fall through to the normal permission prompt. + /// /// [JsonPropertyName("permissionDecision")] public string? PermissionDecision { get; set; } + /// + /// Human-readable reason for the permission decision. + /// [JsonPropertyName("permissionDecisionReason")] public string? PermissionDecisionReason { get; set; } + /// + /// Modified arguments to pass to the tool instead of the original ones. + /// [JsonPropertyName("modifiedArgs")] public object? ModifiedArgs { get; set; } + /// + /// Additional context to inject into the conversation for the language model. + /// [JsonPropertyName("additionalContext")] public string? AdditionalContext { get; set; } + /// + /// Whether to suppress the tool's output from the conversation. + /// [JsonPropertyName("suppressOutput")] public bool? SuppressOutput { get; set; } } +/// +/// Delegate invoked before a tool is executed, allowing modification or denial of the call. +/// public delegate Task PreToolUseHandler(PreToolUseHookInput input, HookInvocation invocation); /// @@ -288,18 +559,33 @@ public class PreToolUseHookOutput /// public class PostToolUseHookInput { + /// + /// Unix timestamp in milliseconds when the tool execution completed. + /// [JsonPropertyName("timestamp")] public long Timestamp { get; set; } + /// + /// Current working directory of the session. + /// [JsonPropertyName("cwd")] public string Cwd { get; set; } = string.Empty; + /// + /// Name of the tool that was executed. + /// [JsonPropertyName("toolName")] public string ToolName { get; set; } = string.Empty; + /// + /// Arguments that were passed to the tool. + /// [JsonPropertyName("toolArgs")] public object? ToolArgs { get; set; } + /// + /// Result returned by the tool execution. + /// [JsonPropertyName("toolResult")] public object? ToolResult { get; set; } } @@ -309,16 +595,28 @@ public class PostToolUseHookInput /// public class PostToolUseHookOutput { + /// + /// Modified result to replace the original tool result. + /// [JsonPropertyName("modifiedResult")] public object? ModifiedResult { get; set; } + /// + /// Additional context to inject into the conversation for the language model. + /// [JsonPropertyName("additionalContext")] public string? AdditionalContext { get; set; } + /// + /// Whether to suppress the tool's output from the conversation. + /// [JsonPropertyName("suppressOutput")] public bool? SuppressOutput { get; set; } } +/// +/// Delegate invoked after a tool has been executed, allowing modification of the result. +/// public delegate Task PostToolUseHandler(PostToolUseHookInput input, HookInvocation invocation); /// @@ -326,12 +624,21 @@ public class PostToolUseHookOutput /// public class UserPromptSubmittedHookInput { + /// + /// Unix timestamp in milliseconds when the prompt was submitted. + /// [JsonPropertyName("timestamp")] public long Timestamp { get; set; } + /// + /// Current working directory of the session. + /// [JsonPropertyName("cwd")] public string Cwd { get; set; } = string.Empty; + /// + /// The user's prompt text. + /// [JsonPropertyName("prompt")] public string Prompt { get; set; } = string.Empty; } @@ -341,16 +648,28 @@ public class UserPromptSubmittedHookInput /// public class UserPromptSubmittedHookOutput { + /// + /// Modified prompt to use instead of the original user prompt. + /// [JsonPropertyName("modifiedPrompt")] public string? ModifiedPrompt { get; set; } + /// + /// Additional context to inject into the conversation for the language model. + /// [JsonPropertyName("additionalContext")] public string? AdditionalContext { get; set; } + /// + /// Whether to suppress the prompt's output from the conversation. + /// [JsonPropertyName("suppressOutput")] public bool? SuppressOutput { get; set; } } +/// +/// Delegate invoked when the user submits a prompt, allowing modification of the prompt. +/// public delegate Task UserPromptSubmittedHandler(UserPromptSubmittedHookInput input, HookInvocation invocation); /// @@ -358,18 +677,32 @@ public class UserPromptSubmittedHookOutput /// public class SessionStartHookInput { + /// + /// Unix timestamp in milliseconds when the session started. + /// [JsonPropertyName("timestamp")] public long Timestamp { get; set; } + /// + /// Current working directory of the session. + /// [JsonPropertyName("cwd")] public string Cwd { get; set; } = string.Empty; /// - /// Source of the session start: "startup", "resume", or "new". + /// Source of the session start. + /// + /// "startup" — initial application startup. + /// "resume" — resuming a previous session. + /// "new" — starting a brand new session. + /// /// [JsonPropertyName("source")] public string Source { get; set; } = string.Empty; + /// + /// Initial prompt provided when the session was started. + /// [JsonPropertyName("initialPrompt")] public string? InitialPrompt { get; set; } } @@ -379,13 +712,22 @@ public class SessionStartHookInput /// public class SessionStartHookOutput { + /// + /// Additional context to inject into the session for the language model. + /// [JsonPropertyName("additionalContext")] public string? AdditionalContext { get; set; } + /// + /// Modified session configuration to apply at startup. + /// [JsonPropertyName("modifiedConfig")] public Dictionary? ModifiedConfig { get; set; } } +/// +/// Delegate invoked when a session starts, allowing injection of context or config changes. +/// public delegate Task SessionStartHandler(SessionStartHookInput input, HookInvocation invocation); /// @@ -393,21 +735,40 @@ public class SessionStartHookOutput /// public class SessionEndHookInput { + /// + /// Unix timestamp in milliseconds when the session ended. + /// [JsonPropertyName("timestamp")] public long Timestamp { get; set; } + /// + /// Current working directory of the session. + /// [JsonPropertyName("cwd")] public string Cwd { get; set; } = string.Empty; /// - /// Reason for session end: "complete", "error", "abort", "timeout", or "user_exit". + /// Reason for session end. + /// + /// "complete" — the session finished normally. + /// "error" — the session ended due to an error. + /// "abort" — the session was aborted. + /// "timeout" — the session timed out. + /// "user_exit" — the user exited the session. + /// /// [JsonPropertyName("reason")] public string Reason { get; set; } = string.Empty; + /// + /// Final message from the assistant before the session ended. + /// [JsonPropertyName("finalMessage")] public string? FinalMessage { get; set; } + /// + /// Error message if the session ended due to an error. + /// [JsonPropertyName("error")] public string? Error { get; set; } } @@ -417,16 +778,28 @@ public class SessionEndHookInput /// public class SessionEndHookOutput { + /// + /// Whether to suppress the session end output from the conversation. + /// [JsonPropertyName("suppressOutput")] public bool? SuppressOutput { get; set; } + /// + /// List of cleanup action identifiers to execute after the session ends. + /// [JsonPropertyName("cleanupActions")] public List? CleanupActions { get; set; } + /// + /// Summary of the session to persist for future reference. + /// [JsonPropertyName("sessionSummary")] public string? SessionSummary { get; set; } } +/// +/// Delegate invoked when a session ends, allowing cleanup actions or summary generation. +/// public delegate Task SessionEndHandler(SessionEndHookInput input, HookInvocation invocation); /// @@ -434,21 +807,39 @@ public class SessionEndHookOutput /// public class ErrorOccurredHookInput { + /// + /// Unix timestamp in milliseconds when the error occurred. + /// [JsonPropertyName("timestamp")] public long Timestamp { get; set; } + /// + /// Current working directory of the session. + /// [JsonPropertyName("cwd")] public string Cwd { get; set; } = string.Empty; + /// + /// Error message describing what went wrong. + /// [JsonPropertyName("error")] public string Error { get; set; } = string.Empty; /// - /// Context of the error: "model_call", "tool_execution", "system", or "user_input". + /// Context of the error. + /// + /// "model_call" — error during a model API call. + /// "tool_execution" — error during tool execution. + /// "system" — internal system error. + /// "user_input" — error processing user input. + /// /// [JsonPropertyName("errorContext")] public string ErrorContext { get; set; } = string.Empty; + /// + /// Whether the error is recoverable and the session can continue. + /// [JsonPropertyName("recoverable")] public bool Recoverable { get; set; } } @@ -458,22 +849,39 @@ public class ErrorOccurredHookInput /// public class ErrorOccurredHookOutput { + /// + /// Whether to suppress the error output from the conversation. + /// [JsonPropertyName("suppressOutput")] public bool? SuppressOutput { get; set; } /// - /// Error handling strategy: "retry", "skip", or "abort". + /// Error handling strategy. + /// + /// "retry" — retry the failed operation. + /// "skip" — skip the failed operation and continue. + /// "abort" — abort the session. + /// /// [JsonPropertyName("errorHandling")] public string? ErrorHandling { get; set; } + /// + /// Number of times to retry the failed operation. + /// [JsonPropertyName("retryCount")] public int? RetryCount { get; set; } + /// + /// Message to display to the user about the error. + /// [JsonPropertyName("userNotification")] public string? UserNotification { get; set; } } +/// +/// Delegate invoked when an error occurs, allowing custom error handling strategies. +/// public delegate Task ErrorOccurredHandler(ErrorOccurredHookInput input, HookInvocation invocation); /// @@ -512,32 +920,61 @@ public class SessionHooks public ErrorOccurredHandler? OnErrorOccurred { get; set; } } +/// +/// Specifies how a custom system message is applied to the session. +/// [JsonConverter(typeof(JsonStringEnumConverter))] public enum SystemMessageMode { + /// Append the custom system message to the default system message. [JsonStringEnumMemberName("append")] Append, + /// Replace the default system message entirely. [JsonStringEnumMemberName("replace")] Replace } +/// +/// Configuration for the system message used in a session. +/// public class SystemMessageConfig { + /// + /// How the system message is applied (append or replace). + /// public SystemMessageMode? Mode { get; set; } + /// + /// Content of the system message. + /// public string? Content { get; set; } } +/// +/// Configuration for a custom model provider. +/// public class ProviderConfig { + /// + /// Provider type identifier (e.g., "openai", "azure"). + /// [JsonPropertyName("type")] public string? Type { get; set; } + /// + /// Wire API format to use (e.g., "chat-completions"). + /// [JsonPropertyName("wireApi")] public string? WireApi { get; set; } + /// + /// Base URL of the provider's API endpoint. + /// [JsonPropertyName("baseUrl")] public string BaseUrl { get; set; } = string.Empty; + /// + /// API key for authenticating with the provider. + /// [JsonPropertyName("apiKey")] public string? ApiKey { get; set; } @@ -549,12 +986,21 @@ public class ProviderConfig [JsonPropertyName("bearerToken")] public string? BearerToken { get; set; } + /// + /// Azure-specific configuration options. + /// [JsonPropertyName("azure")] public AzureOptions? Azure { get; set; } } +/// +/// Azure OpenAI-specific provider options. +/// public class AzureOptions { + /// + /// Azure OpenAI API version to use (e.g., "2024-02-01"). + /// [JsonPropertyName("apiVersion")] public string? ApiVersion { get; set; } } @@ -572,7 +1018,7 @@ public class McpLocalServerConfig /// List of tools to include from this server. Empty list means none. Use "*" for all. /// [JsonPropertyName("tools")] - public List Tools { get; set; } = new(); + public List Tools { get; set; } = []; /// /// Server type. Defaults to "local". @@ -596,7 +1042,7 @@ public class McpLocalServerConfig /// Arguments to pass to the command. /// [JsonPropertyName("args")] - public List Args { get; set; } = new(); + public List Args { get; set; } = []; /// /// Environment variables to pass to the server. @@ -620,7 +1066,7 @@ public class McpRemoteServerConfig /// List of tools to include from this server. Empty list means none. Use "*" for all. /// [JsonPropertyName("tools")] - public List Tools { get; set; } = new(); + public List Tools { get; set; } = []; /// /// Server type. Must be "http" or "sse". @@ -729,6 +1175,9 @@ public class InfiniteSessionConfig public double? BufferExhaustionThreshold { get; set; } } +/// +/// Configuration options for creating a new Copilot session. +/// public class SessionConfig { /// @@ -768,6 +1217,9 @@ protected SessionConfig(SessionConfig? other) WorkingDirectory = other.WorkingDirectory; } + /// + /// Optional session identifier; a new ID is generated if not provided. + /// public string? SessionId { get; set; } /// @@ -776,6 +1228,9 @@ protected SessionConfig(SessionConfig? other) /// public string? ClientName { get; set; } + /// + /// Model identifier to use for this session (e.g., "gpt-4o"). + /// public string? Model { get; set; } /// @@ -791,10 +1246,25 @@ protected SessionConfig(SessionConfig? other) /// public string? ConfigDir { get; set; } + /// + /// Custom tool functions available to the language model during the session. + /// public ICollection? Tools { get; set; } + /// + /// System message configuration for the session. + /// public SystemMessageConfig? SystemMessage { get; set; } + /// + /// List of tool names to allow; only these tools will be available when specified. + /// public List? AvailableTools { get; set; } + /// + /// List of tool names to exclude from the session. + /// public List? ExcludedTools { get; set; } + /// + /// Custom model provider configuration for the session. + /// public ProviderConfig? Provider { get; set; } /// @@ -863,9 +1333,15 @@ protected SessionConfig(SessionConfig? other) /// hooks, infinite session configuration, and delegates) are not deep-cloned; the original /// and the clone will share those nested objects, and changes to them may affect both. /// - public virtual SessionConfig Clone() => new(this); + public virtual SessionConfig Clone() + { + return new(this); + } } +/// +/// Configuration options for resuming an existing Copilot session. +/// public class ResumeSessionConfig { /// @@ -916,6 +1392,9 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) /// public string? Model { get; set; } + /// + /// Custom tool functions available to the language model during the resumed session. + /// public ICollection? Tools { get; set; } /// @@ -935,6 +1414,9 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) /// public List? ExcludedTools { get; set; } + /// + /// Custom model provider configuration for the resumed session. + /// public ProviderConfig? Provider { get; set; } /// @@ -1019,9 +1501,15 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) /// hooks, infinite session configuration, and delegates) are not deep-cloned; the original /// and the clone will share those nested objects, and changes to them may affect both. /// - public virtual ResumeSessionConfig Clone() => new(this); + public virtual ResumeSessionConfig Clone() + { + return new(this); + } } +/// +/// Options for sending a message in a Copilot session. +/// public class MessageOptions { /// @@ -1042,8 +1530,17 @@ protected MessageOptions(MessageOptions? other) Prompt = other.Prompt; } + /// + /// The prompt text to send to the assistant. + /// public string Prompt { get; set; } = string.Empty; + /// + /// File or data attachments to include with the message. + /// public List? Attachments { get; set; } + /// + /// Interaction mode for the message (e.g., "plan", "edit"). + /// public string? Mode { get; set; } /// @@ -1055,9 +1552,15 @@ protected MessageOptions(MessageOptions? other) /// Other reference-type properties (for example attachment items) are not deep-cloned; /// the original and the clone will share those nested objects. /// - public virtual MessageOptions Clone() => new(this); + public virtual MessageOptions Clone() + { + return new(this); + } } +/// +/// Delegate for handling session events emitted during a Copilot session. +/// public delegate void SessionEventHandler(SessionEvent sessionEvent); /// @@ -1090,12 +1593,30 @@ public class SessionListFilter public string? Branch { get; set; } } +/// +/// Metadata describing a Copilot session. +/// public class SessionMetadata { + /// + /// Unique identifier of the session. + /// public string SessionId { get; set; } = string.Empty; + /// + /// Time when the session was created. + /// public DateTime StartTime { get; set; } + /// + /// Time when the session was last modified. + /// public DateTime ModifiedTime { get; set; } + /// + /// Human-readable summary of the session. + /// public string? Summary { get; set; } + /// + /// Whether the session is running on a remote server. + /// public bool IsRemote { get; set; } /// Working directory context (cwd, git info) from session creation. public SessionContext? Context { get; set; } @@ -1106,10 +1627,22 @@ internal class PingRequest public string? Message { get; set; } } +/// +/// Response from a server ping request. +/// public class PingResponse { + /// + /// Echo of the ping message. + /// public string Message { get; set; } = string.Empty; + /// + /// Server timestamp when the ping was processed. + /// public long Timestamp { get; set; } + /// + /// Protocol version supported by the server. + /// public int? ProtocolVersion { get; set; } } @@ -1136,7 +1669,17 @@ public class GetAuthStatusResponse [JsonPropertyName("isAuthenticated")] public bool IsAuthenticated { get; set; } - /// Authentication type (user, env, gh-cli, hmac, api-key, token) + /// + /// Authentication type. + /// + /// "user" — authenticated via user login. + /// "env" — authenticated via environment variable. + /// "gh-cli" — authenticated via the GitHub CLI. + /// "hmac" — authenticated via HMAC signature. + /// "api-key" — authenticated via API key. + /// "token" — authenticated via explicit token. + /// + /// [JsonPropertyName("authType")] public string? AuthType { get; set; } @@ -1158,12 +1701,21 @@ public class GetAuthStatusResponse /// public class ModelVisionLimits { + /// + /// List of supported image MIME types (e.g., "image/png", "image/jpeg"). + /// [JsonPropertyName("supported_media_types")] - public List SupportedMediaTypes { get; set; } = new(); + public List SupportedMediaTypes { get; set; } = []; + /// + /// Maximum number of images allowed in a single prompt. + /// [JsonPropertyName("max_prompt_images")] public int MaxPromptImages { get; set; } + /// + /// Maximum size in bytes for a single prompt image. + /// [JsonPropertyName("max_prompt_image_size")] public int MaxPromptImageSize { get; set; } } @@ -1173,12 +1725,21 @@ public class ModelVisionLimits /// public class ModelLimits { + /// + /// Maximum number of tokens allowed in the prompt. + /// [JsonPropertyName("max_prompt_tokens")] public int? MaxPromptTokens { get; set; } + /// + /// Maximum total tokens in the context window. + /// [JsonPropertyName("max_context_window_tokens")] public int MaxContextWindowTokens { get; set; } + /// + /// Vision-specific limits for the model. + /// [JsonPropertyName("vision")] public ModelVisionLimits? Vision { get; set; } } @@ -1188,6 +1749,9 @@ public class ModelLimits /// public class ModelSupports { + /// + /// Whether this model supports image/vision inputs. + /// [JsonPropertyName("vision")] public bool Vision { get; set; } @@ -1203,9 +1767,15 @@ public class ModelSupports /// public class ModelCapabilities { + /// + /// Feature support flags for the model. + /// [JsonPropertyName("supports")] public ModelSupports Supports { get; set; } = new(); + /// + /// Token and resource limits for the model. + /// [JsonPropertyName("limits")] public ModelLimits Limits { get; set; } = new(); } @@ -1215,9 +1785,15 @@ public class ModelCapabilities /// public class ModelPolicy { + /// + /// Policy state of the model (e.g., "enabled", "disabled"). + /// [JsonPropertyName("state")] public string State { get; set; } = string.Empty; + /// + /// Terms or conditions associated with using the model. + /// [JsonPropertyName("terms")] public string Terms { get; set; } = string.Empty; } @@ -1227,6 +1803,9 @@ public class ModelPolicy /// public class ModelBilling { + /// + /// Billing cost multiplier relative to the base model rate. + /// [JsonPropertyName("multiplier")] public double Multiplier { get; set; } } @@ -1270,8 +1849,11 @@ public class ModelInfo /// public class GetModelsResponse { + /// + /// List of available models. + /// [JsonPropertyName("models")] - public List Models { get; set; } = new(); + public List Models { get; set; } = []; } // ============================================================================ @@ -1283,10 +1865,15 @@ public class GetModelsResponse /// public static class SessionLifecycleEventTypes { + /// A new session was created. public const string Created = "session.created"; + /// A session was deleted. public const string Deleted = "session.deleted"; + /// A session was updated. public const string Updated = "session.updated"; + /// A session was brought to the foreground. public const string Foreground = "session.foreground"; + /// A session was moved to the background. public const string Background = "session.background"; } @@ -1295,12 +1882,21 @@ public static class SessionLifecycleEventTypes /// public class SessionLifecycleEventMetadata { + /// + /// ISO 8601 timestamp when the session was created. + /// [JsonPropertyName("startTime")] public string StartTime { get; set; } = string.Empty; + /// + /// ISO 8601 timestamp when the session was last modified. + /// [JsonPropertyName("modifiedTime")] public string ModifiedTime { get; set; } = string.Empty; + /// + /// Human-readable summary of the session. + /// [JsonPropertyName("summary")] public string? Summary { get; set; } } @@ -1310,12 +1906,21 @@ public class SessionLifecycleEventMetadata /// public class SessionLifecycleEvent { + /// + /// Type of lifecycle event (see ). + /// [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; + /// + /// Identifier of the session this event pertains to. + /// [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; + /// + /// Metadata associated with the session lifecycle event. + /// [JsonPropertyName("metadata")] public SessionLifecycleEventMetadata? Metadata { get; set; } } @@ -1325,9 +1930,15 @@ public class SessionLifecycleEvent /// public class GetForegroundSessionResponse { + /// + /// Identifier of the current foreground session, or null if none. + /// [JsonPropertyName("sessionId")] public string? SessionId { get; set; } + /// + /// Workspace path associated with the foreground session. + /// [JsonPropertyName("workspacePath")] public string? WorkspacePath { get; set; } } @@ -1337,9 +1948,15 @@ public class GetForegroundSessionResponse /// public class SetForegroundSessionResponse { + /// + /// Whether the foreground session was set successfully. + /// [JsonPropertyName("success")] public bool Success { get; set; } + /// + /// Error message if the operation failed. + /// [JsonPropertyName("error")] public string? Error { get; set; } } diff --git a/dotnet/src/build/GitHub.Copilot.SDK.targets b/dotnet/src/build/GitHub.Copilot.SDK.targets index b290e2b9c..9bc98f0f7 100644 --- a/dotnet/src/build/GitHub.Copilot.SDK.targets +++ b/dotnet/src/build/GitHub.Copilot.SDK.targets @@ -66,6 +66,8 @@ <_CopilotArchivePath>$(_CopilotCacheDir)\copilot.tgz <_CopilotNormalizedRegistryUrl>$([System.String]::Copy('$(CopilotNpmRegistryUrl)').TrimEnd('/')) <_CopilotDownloadUrl>$(_CopilotNormalizedRegistryUrl)/@github/copilot-$(_CopilotPlatform)/-/copilot-$(_CopilotPlatform)-$(CopilotCliVersion).tgz + + <_CopilotCliDownloadTimeoutMs>$([System.Convert]::ToInt32($([MSBuild]::Multiply($(CopilotCliDownloadTimeout), 1000)))) @@ -75,7 +77,7 @@ diff --git a/dotnet/test/AgentAndCompactRpcTests.cs b/dotnet/test/AgentAndCompactRpcTests.cs new file mode 100644 index 000000000..5f40d4e2b --- /dev/null +++ b/dotnet/test/AgentAndCompactRpcTests.cs @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.SDK.Rpc; +using GitHub.Copilot.SDK.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.SDK.Test; + +public class AgentAndCompactRpcTests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "agent_and_compact_rpc", output) +{ + [Fact] + public async Task Should_List_Available_Custom_Agents() + { + var customAgents = new List + { + new() + { + Name = "test-agent", + DisplayName = "Test Agent", + Description = "A test agent", + Prompt = "You are a test agent." + }, + new() + { + Name = "another-agent", + DisplayName = "Another Agent", + Description = "Another test agent", + Prompt = "You are another agent." + } + }; + + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); + + var result = await session.Rpc.Agent.ListAsync(); + Assert.NotNull(result.Agents); + Assert.Equal(2, result.Agents.Count); + Assert.Equal("test-agent", result.Agents[0].Name); + Assert.Equal("Test Agent", result.Agents[0].DisplayName); + Assert.Equal("A test agent", result.Agents[0].Description); + Assert.Equal("another-agent", result.Agents[1].Name); + } + + [Fact] + public async Task Should_Return_Null_When_No_Agent_Is_Selected() + { + var customAgents = new List + { + new() + { + Name = "test-agent", + DisplayName = "Test Agent", + Description = "A test agent", + Prompt = "You are a test agent." + } + }; + + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); + + var result = await session.Rpc.Agent.GetCurrentAsync(); + Assert.Null(result.Agent); + } + + [Fact] + public async Task Should_Select_And_Get_Current_Agent() + { + var customAgents = new List + { + new() + { + Name = "test-agent", + DisplayName = "Test Agent", + Description = "A test agent", + Prompt = "You are a test agent." + } + }; + + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); + + // Select the agent + var selectResult = await session.Rpc.Agent.SelectAsync("test-agent"); + Assert.NotNull(selectResult.Agent); + Assert.Equal("test-agent", selectResult.Agent.Name); + Assert.Equal("Test Agent", selectResult.Agent.DisplayName); + + // Verify getCurrent returns the selected agent + var currentResult = await session.Rpc.Agent.GetCurrentAsync(); + Assert.NotNull(currentResult.Agent); + Assert.Equal("test-agent", currentResult.Agent.Name); + } + + [Fact] + public async Task Should_Deselect_Current_Agent() + { + var customAgents = new List + { + new() + { + Name = "test-agent", + DisplayName = "Test Agent", + Description = "A test agent", + Prompt = "You are a test agent." + } + }; + + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); + + // Select then deselect + await session.Rpc.Agent.SelectAsync("test-agent"); + await session.Rpc.Agent.DeselectAsync(); + + // Verify no agent is selected + var currentResult = await session.Rpc.Agent.GetCurrentAsync(); + Assert.Null(currentResult.Agent); + } + + [Fact] + public async Task Should_Return_Empty_List_When_No_Custom_Agents_Configured() + { + var session = await CreateSessionAsync(); + + var result = await session.Rpc.Agent.ListAsync(); + Assert.Empty(result.Agents); + } + + [Fact] + public async Task Should_Compact_Session_History_After_Messages() + { + var session = await CreateSessionAsync(); + + // Send a message to create some history + await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" }); + + // Compact the session + var result = await session.Rpc.Compaction.CompactAsync(); + Assert.NotNull(result); + } +} diff --git a/dotnet/test/AskUserTests.cs b/dotnet/test/AskUserTests.cs index 55a563674..d3f273996 100644 --- a/dotnet/test/AskUserTests.cs +++ b/dotnet/test/AskUserTests.cs @@ -15,7 +15,7 @@ public async Task Should_Invoke_User_Input_Handler_When_Model_Uses_Ask_User_Tool { var userInputRequests = new List(); CopilotSession? session = null; - session = await Client.CreateSessionAsync(new SessionConfig + session = await CreateSessionAsync(new SessionConfig { OnUserInputRequest = (request, invocation) => { @@ -49,7 +49,7 @@ public async Task Should_Receive_Choices_In_User_Input_Request() { var userInputRequests = new List(); - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnUserInputRequest = (request, invocation) => { @@ -82,7 +82,7 @@ public async Task Should_Handle_Freeform_User_Input_Response() var userInputRequests = new List(); var freeformAnswer = "This is my custom freeform answer that was not in the choices"; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnUserInputRequest = (request, invocation) => { diff --git a/dotnet/test/ClientTests.cs b/dotnet/test/ClientTests.cs index ee5b73bc7..3c3f3bdaa 100644 --- a/dotnet/test/ClientTests.cs +++ b/dotnet/test/ClientTests.cs @@ -59,7 +59,7 @@ public async Task Should_Force_Stop_Without_Cleanup() { using var client = new CopilotClient(new CopilotClientOptions()); - await client.CreateSessionAsync(); + await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); await client.ForceStopAsync(); Assert.Equal(ConnectionState.Disconnected, client.State); @@ -149,14 +149,14 @@ public async Task Should_List_Models_When_Authenticated() } [Fact] - public void Should_Accept_GithubToken_Option() + public void Should_Accept_GitHubToken_Option() { var options = new CopilotClientOptions { - GithubToken = "gho_test_token" + GitHubToken = "gho_test_token" }; - Assert.Equal("gho_test_token", options.GithubToken); + Assert.Equal("gho_test_token", options.GitHubToken); } [Fact] @@ -179,11 +179,11 @@ public void Should_Allow_Explicit_UseLoggedInUser_False() } [Fact] - public void Should_Allow_Explicit_UseLoggedInUser_True_With_GithubToken() + public void Should_Allow_Explicit_UseLoggedInUser_True_With_GitHubToken() { var options = new CopilotClientOptions { - GithubToken = "gho_test_token", + GitHubToken = "gho_test_token", UseLoggedInUser = true }; @@ -191,14 +191,14 @@ public void Should_Allow_Explicit_UseLoggedInUser_True_With_GithubToken() } [Fact] - public void Should_Throw_When_GithubToken_Used_With_CliUrl() + public void Should_Throw_When_GitHubToken_Used_With_CliUrl() { Assert.Throws(() => { _ = new CopilotClient(new CopilotClientOptions { CliUrl = "localhost:8080", - GithubToken = "gho_test_token" + GitHubToken = "gho_test_token" }); }); } @@ -220,7 +220,7 @@ public void Should_Throw_When_UseLoggedInUser_Used_With_CliUrl() public async Task Should_Not_Throw_When_Disposing_Session_After_Stopping_Client() { await using var client = new CopilotClient(new CopilotClientOptions()); - await using var session = await client.CreateSessionAsync(); + await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); await client.StopAsync(); } @@ -230,14 +230,11 @@ public async Task Should_Report_Error_With_Stderr_When_CLI_Fails_To_Start() { var client = new CopilotClient(new CopilotClientOptions { - CliArgs = new[] { "--nonexistent-flag-for-testing" }, + CliArgs = ["--nonexistent-flag-for-testing"], UseStdio = true }); - var ex = await Assert.ThrowsAsync(async () => - { - await client.StartAsync(); - }); + var ex = await Assert.ThrowsAsync(() => client.StartAsync()); var errorMessage = ex.Message; // Verify we get the stderr output in the error message @@ -247,7 +244,7 @@ public async Task Should_Report_Error_With_Stderr_When_CLI_Fails_To_Start() // Verify subsequent calls also fail (don't hang) var ex2 = await Assert.ThrowsAnyAsync(async () => { - var session = await client.CreateSessionAsync(); + var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); await session.SendAsync(new MessageOptions { Prompt = "test" }); }); Assert.Contains("exited", ex2.Message, StringComparison.OrdinalIgnoreCase); @@ -255,4 +252,26 @@ public async Task Should_Report_Error_With_Stderr_When_CLI_Fails_To_Start() // Cleanup - ForceStop should handle the disconnected state gracefully try { await client.ForceStopAsync(); } catch (Exception) { /* Expected */ } } + + [Fact] + public async Task Should_Throw_When_CreateSession_Called_Without_PermissionHandler() + { + using var client = new CopilotClient(new CopilotClientOptions()); + + var ex = await Assert.ThrowsAsync(() => client.CreateSessionAsync(new SessionConfig())); + + Assert.Contains("OnPermissionRequest", ex.Message); + Assert.Contains("is required", ex.Message); + } + + [Fact] + public async Task Should_Throw_When_ResumeSession_Called_Without_PermissionHandler() + { + using var client = new CopilotClient(new CopilotClientOptions()); + + var ex = await Assert.ThrowsAsync(() => client.ResumeSessionAsync("some-session-id", new())); + + Assert.Contains("OnPermissionRequest", ex.Message); + Assert.Contains("is required", ex.Message); + } } diff --git a/dotnet/test/CloneTests.cs b/dotnet/test/CloneTests.cs index 45eaaae16..8982c5d64 100644 --- a/dotnet/test/CloneTests.cs +++ b/dotnet/test/CloneTests.cs @@ -24,7 +24,7 @@ public void CopilotClientOptions_Clone_CopiesAllProperties() AutoStart = false, AutoRestart = false, Environment = new Dictionary { ["KEY"] = "value" }, - GithubToken = "ghp_test", + GitHubToken = "ghp_test", UseLoggedInUser = false, }; @@ -40,7 +40,7 @@ public void CopilotClientOptions_Clone_CopiesAllProperties() Assert.Equal(original.AutoStart, clone.AutoStart); Assert.Equal(original.AutoRestart, clone.AutoRestart); Assert.Equal(original.Environment, clone.Environment); - Assert.Equal(original.GithubToken, clone.GithubToken); + Assert.Equal(original.GitHubToken, clone.GitHubToken); Assert.Equal(original.UseLoggedInUser, clone.UseLoggedInUser); } diff --git a/dotnet/test/CompactionTests.cs b/dotnet/test/CompactionTests.cs index af76508c7..c1cbc42df 100644 --- a/dotnet/test/CompactionTests.cs +++ b/dotnet/test/CompactionTests.cs @@ -15,7 +15,7 @@ public class CompactionTests(E2ETestFixture fixture, ITestOutputHelper output) : public async Task Should_Trigger_Compaction_With_Low_Threshold_And_Emit_Events() { // Create session with very low compaction thresholds to trigger compaction quickly - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { InfiniteSessions = new InfiniteSessionConfig { @@ -45,7 +45,7 @@ public async Task Should_Trigger_Compaction_With_Low_Threshold_And_Emit_Events() // Send multiple messages to fill up the context window await session.SendAndWaitAsync(new MessageOptions { - Prompt = "Tell me a long story about a dragon. Be very detailed." + Prompt = "Tell me a story about a dragon. Be detailed." }); await session.SendAndWaitAsync(new MessageOptions { @@ -84,7 +84,7 @@ await session.SendAndWaitAsync(new MessageOptions [Fact] public async Task Should_Not_Emit_Compaction_Events_When_Infinite_Sessions_Disabled() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { InfiniteSessions = new InfiniteSessionConfig { diff --git a/dotnet/test/GitHub.Copilot.SDK.Test.csproj b/dotnet/test/GitHub.Copilot.SDK.Test.csproj index cba1d863e..fbc9f17c3 100644 --- a/dotnet/test/GitHub.Copilot.SDK.Test.csproj +++ b/dotnet/test/GitHub.Copilot.SDK.Test.csproj @@ -1,10 +1,6 @@ - net8.0 - enable - enable - true false @@ -19,17 +15,17 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/dotnet/test/Harness/CapiProxy.cs b/dotnet/test/Harness/CapiProxy.cs index a03502979..e6208f251 100644 --- a/dotnet/test/Harness/CapiProxy.cs +++ b/dotnet/test/Harness/CapiProxy.cs @@ -12,7 +12,7 @@ namespace GitHub.Copilot.SDK.Test.Harness; -public partial class CapiProxy : IAsyncDisposable +public sealed partial class CapiProxy : IAsyncDisposable { private Process? _process; private Task? _startupTask; @@ -129,10 +129,13 @@ public async Task> GetExchangesAsync() using var client = new HttpClient(); return await client.GetFromJsonAsync($"{url}/exchanges", CapiProxyJsonContext.Default.ListParsedHttpExchange) - ?? new List(); + ?? []; } - public async ValueTask DisposeAsync() => await StopAsync(); + public async ValueTask DisposeAsync() + { + await StopAsync(); + } private static string FindRepoRoot() { diff --git a/dotnet/test/Harness/E2ETestBase.cs b/dotnet/test/Harness/E2ETestBase.cs index 8727e1239..e982090cb 100644 --- a/dotnet/test/Harness/E2ETestBase.cs +++ b/dotnet/test/Harness/E2ETestBase.cs @@ -40,11 +40,40 @@ public async Task InitializeAsync() await Ctx.ConfigureForTestAsync(_snapshotCategory, _testName); } - public Task DisposeAsync() => Task.CompletedTask; + public Task DisposeAsync() + { + return Task.CompletedTask; + } + + /// + /// Creates a session with a default config that approves all permissions. + /// Convenience wrapper for E2E tests. + /// + protected Task CreateSessionAsync(SessionConfig? config = null) + { + config ??= new SessionConfig(); + config.OnPermissionRequest ??= PermissionHandler.ApproveAll; + return Client.CreateSessionAsync(config); + } + + /// + /// Resumes a session with a default config that approves all permissions. + /// Convenience wrapper for E2E tests. + /// + protected Task ResumeSessionAsync(string sessionId, ResumeSessionConfig? config = null) + { + config ??= new ResumeSessionConfig(); + config.OnPermissionRequest ??= PermissionHandler.ApproveAll; + return Client.ResumeSessionAsync(sessionId, config); + } - protected static string GetSystemMessage(ParsedHttpExchange exchange) => - exchange.Request.Messages.FirstOrDefault(m => m.Role == "system")?.Content ?? string.Empty; + protected static string GetSystemMessage(ParsedHttpExchange exchange) + { + return exchange.Request.Messages.FirstOrDefault(m => m.Role == "system")?.Content ?? string.Empty; + } - protected static List GetToolNames(ParsedHttpExchange exchange) => - exchange.Request.Tools?.Select(t => t.Function.Name).ToList() ?? new(); + protected static List GetToolNames(ParsedHttpExchange exchange) + { + return exchange.Request.Tools?.Select(t => t.Function.Name).ToList() ?? []; + } } diff --git a/dotnet/test/Harness/E2ETestContext.cs b/dotnet/test/Harness/E2ETestContext.cs index b8f3bdeb1..0da0fdad5 100644 --- a/dotnet/test/Harness/E2ETestContext.cs +++ b/dotnet/test/Harness/E2ETestContext.cs @@ -7,7 +7,7 @@ namespace GitHub.Copilot.SDK.Test.Harness; -public class E2ETestContext : IAsyncDisposable +public sealed class E2ETestContext : IAsyncDisposable { public string HomeDir { get; } public string WorkDir { get; } @@ -74,7 +74,10 @@ public async Task ConfigureForTestAsync(string testFile, [CallerMemberName] stri await _proxy.ConfigureAsync(snapshotPath, WorkDir); } - public Task> GetExchangesAsync() => _proxy.GetExchangesAsync(); + public Task> GetExchangesAsync() + { + return _proxy.GetExchangesAsync(); + } public IReadOnlyDictionary GetEnvironment() { @@ -89,18 +92,22 @@ public IReadOnlyDictionary GetEnvironment() return env!; } - public CopilotClient CreateClient() => new(new CopilotClientOptions + public CopilotClient CreateClient(bool useStdio = true) { - Cwd = WorkDir, - CliPath = GetCliPath(_repoRoot), - Environment = GetEnvironment(), - GithubToken = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) ? "fake-token-for-e2e-tests" : null, - }); + return new(new CopilotClientOptions + { + Cwd = WorkDir, + CliPath = GetCliPath(_repoRoot), + Environment = GetEnvironment(), + UseStdio = useStdio, + GitHubToken = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")) ? "fake-token-for-e2e-tests" : null, + }); + } public async ValueTask DisposeAsync() { // Skip writing snapshots in CI to avoid corrupting them on test failures - var isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")); + var isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")); await _proxy.StopAsync(skipWritingCache: isCI); try { if (Directory.Exists(HomeDir)) Directory.Delete(HomeDir, true); } catch { } diff --git a/dotnet/test/HooksTests.cs b/dotnet/test/HooksTests.cs index 44a6e66c2..a37ef3c15 100644 --- a/dotnet/test/HooksTests.cs +++ b/dotnet/test/HooksTests.cs @@ -15,7 +15,7 @@ public async Task Should_Invoke_PreToolUse_Hook_When_Model_Runs_A_Tool() { var preToolUseInputs = new List(); CopilotSession? session = null; - session = await Client.CreateSessionAsync(new SessionConfig + session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks @@ -51,7 +51,7 @@ public async Task Should_Invoke_PostToolUse_Hook_After_Model_Runs_A_Tool() { var postToolUseInputs = new List(); CopilotSession? session = null; - session = await Client.CreateSessionAsync(new SessionConfig + session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks @@ -89,7 +89,7 @@ public async Task Should_Invoke_Both_PreToolUse_And_PostToolUse_Hooks_For_Single var preToolUseInputs = new List(); var postToolUseInputs = new List(); - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks @@ -131,7 +131,7 @@ public async Task Should_Deny_Tool_Execution_When_PreToolUse_Returns_Deny() { var preToolUseInputs = new List(); - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, Hooks = new SessionHooks diff --git a/dotnet/test/McpAndAgentsTests.cs b/dotnet/test/McpAndAgentsTests.cs index 644a70bf3..1d35ffda4 100644 --- a/dotnet/test/McpAndAgentsTests.cs +++ b/dotnet/test/McpAndAgentsTests.cs @@ -24,7 +24,7 @@ public async Task Should_Accept_MCP_Server_Configuration_On_Session_Create() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { McpServers = mcpServers }); @@ -45,7 +45,7 @@ public async Task Should_Accept_MCP_Server_Configuration_On_Session_Create() public async Task Should_Accept_MCP_Server_Configuration_On_Session_Resume() { // Create a session first - var session1 = await Client.CreateSessionAsync(); + var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); @@ -61,7 +61,7 @@ public async Task Should_Accept_MCP_Server_Configuration_On_Session_Resume() } }; - var session2 = await Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { McpServers = mcpServers }); @@ -96,7 +96,7 @@ public async Task Should_Handle_Multiple_MCP_Servers() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { McpServers = mcpServers }); @@ -120,7 +120,7 @@ public async Task Should_Accept_Custom_Agent_Configuration_On_Session_Create() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); @@ -141,7 +141,7 @@ public async Task Should_Accept_Custom_Agent_Configuration_On_Session_Create() public async Task Should_Accept_Custom_Agent_Configuration_On_Session_Resume() { // Create a session first - var session1 = await Client.CreateSessionAsync(); + var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); @@ -157,7 +157,7 @@ public async Task Should_Accept_Custom_Agent_Configuration_On_Session_Resume() } }; - var session2 = await Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { CustomAgents = customAgents }); @@ -187,7 +187,7 @@ public async Task Should_Handle_Custom_Agent_With_Tools_Configuration() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); @@ -220,7 +220,7 @@ public async Task Should_Handle_Custom_Agent_With_MCP_Servers() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); @@ -251,7 +251,7 @@ public async Task Should_Handle_Multiple_Custom_Agents() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { CustomAgents = customAgents }); @@ -277,7 +277,7 @@ public async Task Should_Pass_Literal_Env_Values_To_Mcp_Server_Subprocess() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { McpServers = mcpServers, OnPermissionRequest = PermissionHandler.ApproveAll, @@ -321,7 +321,7 @@ public async Task Should_Accept_Both_MCP_Servers_And_Custom_Agents() } }; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { McpServers = mcpServers, CustomAgents = customAgents diff --git a/dotnet/test/MultiClientTests.cs b/dotnet/test/MultiClientTests.cs new file mode 100644 index 000000000..131fd31d0 --- /dev/null +++ b/dotnet/test/MultiClientTests.cs @@ -0,0 +1,348 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Reflection; +using System.Text.RegularExpressions; +using GitHub.Copilot.SDK.Test.Harness; +using Microsoft.Extensions.AI; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.SDK.Test; + +/// +/// Custom fixture for multi-client tests that uses TCP mode so a second client can connect. +/// +public class MultiClientTestFixture : IAsyncLifetime +{ + public E2ETestContext Ctx { get; private set; } = null!; + public CopilotClient Client1 { get; private set; } = null!; + + public async Task InitializeAsync() + { + Ctx = await E2ETestContext.CreateAsync(); + Client1 = Ctx.CreateClient(useStdio: false); + } + + public async Task DisposeAsync() + { + if (Client1 is not null) + { + await Client1.ForceStopAsync(); + } + + await Ctx.DisposeAsync(); + } +} + +public class MultiClientTests : IClassFixture, IAsyncLifetime +{ + private readonly MultiClientTestFixture _fixture; + private readonly string _testName; + private CopilotClient? _client2; + + private E2ETestContext Ctx => _fixture.Ctx; + private CopilotClient Client1 => _fixture.Client1; + + public MultiClientTests(MultiClientTestFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _testName = GetTestName(output); + } + + private static string GetTestName(ITestOutputHelper output) + { + var type = output.GetType(); + var testField = type.GetField("test", BindingFlags.Instance | BindingFlags.NonPublic); + var test = (ITest?)testField?.GetValue(output); + return test?.TestCase.TestMethod.Method.Name ?? throw new InvalidOperationException("Couldn't find test name"); + } + + public async Task InitializeAsync() + { + await Ctx.ConfigureForTestAsync("multi_client", _testName); + + // Trigger connection so we can read the port + var initSession = await Client1.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + await initSession.DisposeAsync(); + + var port = Client1.ActualPort + ?? throw new InvalidOperationException("Client1 is not using TCP mode; ActualPort is null"); + + _client2 = new CopilotClient(new CopilotClientOptions + { + CliUrl = $"localhost:{port}", + }); + } + + public async Task DisposeAsync() + { + if (_client2 is not null) + { + await _client2.ForceStopAsync(); + _client2 = null; + } + } + + private CopilotClient Client2 => _client2 ?? throw new InvalidOperationException("Client2 not initialized"); + + [Fact] + public async Task Both_Clients_See_Tool_Request_And_Completion_Events() + { + var tool = AIFunctionFactory.Create(MagicNumber, "magic_number"); + + var session1 = await Client1.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Tools = [tool], + }); + + var session2 = await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + // Set up event waiters BEFORE sending the prompt to avoid race conditions + var client1Requested = new TaskCompletionSource(); + var client2Requested = new TaskCompletionSource(); + var client1Completed = new TaskCompletionSource(); + var client2Completed = new TaskCompletionSource(); + + using var sub1 = session1.On(evt => + { + if (evt is ExternalToolRequestedEvent) client1Requested.TrySetResult(true); + if (evt is ExternalToolCompletedEvent) client1Completed.TrySetResult(true); + }); + using var sub2 = session2.On(evt => + { + if (evt is ExternalToolRequestedEvent) client2Requested.TrySetResult(true); + if (evt is ExternalToolCompletedEvent) client2Completed.TrySetResult(true); + }); + + var response = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the magic_number tool with seed 'hello' and tell me the result", + }); + + Assert.NotNull(response); + Assert.Contains("MAGIC_hello_42", response!.Data.Content ?? string.Empty); + + // Wait for all broadcast events to arrive on both clients + var timeout = Task.Delay(TimeSpan.FromSeconds(10)); + var allEvents = Task.WhenAll( + client1Requested.Task, client2Requested.Task, + client1Completed.Task, client2Completed.Task); + Assert.Equal(allEvents, await Task.WhenAny(allEvents, timeout)); + + await session2.DisposeAsync(); + + [Description("Returns a magic number")] + static string MagicNumber([Description("A seed value")] string seed) => $"MAGIC_{seed}_42"; + } + + [Fact] + public async Task One_Client_Approves_Permission_And_Both_See_The_Result() + { + var client1PermissionRequests = new List(); + + var session1 = await Client1.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = (request, _) => + { + client1PermissionRequests.Add(request); + return Task.FromResult(new PermissionRequestResult + { + Kind = PermissionRequestResultKind.Approved, + }); + }, + }); + + // Client 2 resumes — its handler never completes, so only client 1's approval takes effect + var session2 = await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + { + OnPermissionRequest = (_, _) => new TaskCompletionSource().Task, + }); + + var client1Events = new ConcurrentBag(); + var client2Events = new ConcurrentBag(); + + using var sub1 = session1.On(evt => client1Events.Add(evt)); + using var sub2 = session2.On(evt => client2Events.Add(evt)); + + var response = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Create a file called hello.txt containing the text 'hello world'", + }); + + Assert.NotNull(response); + Assert.NotEmpty(client1PermissionRequests); + + Assert.Contains(client1Events, e => e is PermissionRequestedEvent); + Assert.Contains(client2Events, e => e is PermissionRequestedEvent); + Assert.Contains(client1Events, e => e is PermissionCompletedEvent); + Assert.Contains(client2Events, e => e is PermissionCompletedEvent); + + foreach (var evt in client1Events.OfType() + .Concat(client2Events.OfType())) + { + Assert.Equal(PermissionCompletedDataResultKind.Approved, evt.Data.Result.Kind); + } + + await session2.DisposeAsync(); + } + + [Fact] + public async Task One_Client_Rejects_Permission_And_Both_See_The_Result() + { + var session1 = await Client1.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = (_, _) => Task.FromResult(new PermissionRequestResult + { + Kind = PermissionRequestResultKind.DeniedInteractivelyByUser, + }), + }); + + // Client 2 resumes — its handler never completes + var session2 = await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + { + OnPermissionRequest = (_, _) => new TaskCompletionSource().Task, + }); + + var client1Events = new ConcurrentBag(); + var client2Events = new ConcurrentBag(); + + using var sub1 = session1.On(evt => client1Events.Add(evt)); + using var sub2 = session2.On(evt => client2Events.Add(evt)); + + // Write a file so the agent has something to edit + await File.WriteAllTextAsync(Path.Combine(Ctx.WorkDir, "protected.txt"), "protected content"); + + await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Edit protected.txt and replace 'protected' with 'hacked'.", + }); + + // Verify the file was NOT modified + var content = await File.ReadAllTextAsync(Path.Combine(Ctx.WorkDir, "protected.txt")); + Assert.Equal("protected content", content); + + Assert.Contains(client1Events, e => e is PermissionRequestedEvent); + Assert.Contains(client2Events, e => e is PermissionRequestedEvent); + + foreach (var evt in client1Events.OfType() + .Concat(client2Events.OfType())) + { + Assert.Equal(PermissionCompletedDataResultKind.DeniedInteractivelyByUser, evt.Data.Result.Kind); + } + + await session2.DisposeAsync(); + } + + [Fact] + public async Task Two_Clients_Register_Different_Tools_And_Agent_Uses_Both() + { + var toolA = AIFunctionFactory.Create(CityLookup, "city_lookup"); + var toolB = AIFunctionFactory.Create(CurrencyLookup, "currency_lookup"); + + var session1 = await Client1.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Tools = [toolA], + }); + + var session2 = await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Tools = [toolB], + }); + + // Send prompts sequentially to avoid nondeterministic tool_call ordering + var response1 = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the city_lookup tool with countryCode 'US' and tell me the result.", + }); + Assert.NotNull(response1); + Assert.Contains("CITY_FOR_US", response1!.Data.Content ?? string.Empty); + + var response2 = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Now use the currency_lookup tool with countryCode 'US' and tell me the result.", + }); + Assert.NotNull(response2); + Assert.Contains("CURRENCY_FOR_US", response2!.Data.Content ?? string.Empty); + + await session2.DisposeAsync(); + + [Description("Returns a city name for a given country code")] + static string CityLookup([Description("A two-letter country code")] string countryCode) => $"CITY_FOR_{countryCode}"; + + [Description("Returns a currency for a given country code")] + static string CurrencyLookup([Description("A two-letter country code")] string countryCode) => $"CURRENCY_FOR_{countryCode}"; + } + + [Fact] + public async Task Disconnecting_Client_Removes_Its_Tools() + { + var toolA = AIFunctionFactory.Create(StableTool, "stable_tool"); + var toolB = AIFunctionFactory.Create(EphemeralTool, "ephemeral_tool"); + + var session1 = await Client1.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Tools = [toolA], + }); + + await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + Tools = [toolB], + }); + + // Verify both tools work before disconnect (sequential to avoid nondeterministic tool_call ordering) + var stableResponse = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the stable_tool with input 'test1' and tell me the result.", + }); + Assert.NotNull(stableResponse); + Assert.Contains("STABLE_test1", stableResponse!.Data.Content ?? string.Empty); + + var ephemeralResponse = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the ephemeral_tool with input 'test2' and tell me the result.", + }); + Assert.NotNull(ephemeralResponse); + Assert.Contains("EPHEMERAL_test2", ephemeralResponse!.Data.Content ?? string.Empty); + + // Disconnect client 2 + await Client2.ForceStopAsync(); + await Task.Delay(500); // Let the server process the disconnection + + // Recreate client2 for cleanup + var port = Client1.ActualPort!.Value; + _client2 = new CopilotClient(new CopilotClientOptions + { + CliUrl = $"localhost:{port}", + }); + + // Now only stable_tool should be available + var afterResponse = await session1.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available.", + }); + Assert.NotNull(afterResponse); + Assert.Contains("STABLE_still_here", afterResponse!.Data.Content ?? string.Empty); + Assert.DoesNotContain("EPHEMERAL_", afterResponse!.Data.Content ?? string.Empty); + + [Description("A tool that persists across disconnects")] + static string StableTool([Description("Input value")] string input) => $"STABLE_{input}"; + + [Description("A tool that will disappear when its client disconnects")] + static string EphemeralTool([Description("Input value")] string input) => $"EPHEMERAL_{input}"; + } +} diff --git a/dotnet/test/PermissionRequestResultKindTests.cs b/dotnet/test/PermissionRequestResultKindTests.cs new file mode 100644 index 000000000..d0cfed6f0 --- /dev/null +++ b/dotnet/test/PermissionRequestResultKindTests.cs @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Text.Json; +using Xunit; + +namespace GitHub.Copilot.SDK.Test; + +public class PermissionRequestResultKindTests +{ + private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = TestJsonContext.Default, + }; + + [Fact] + public void WellKnownKinds_HaveExpectedValues() + { + Assert.Equal("approved", PermissionRequestResultKind.Approved.Value); + Assert.Equal("denied-by-rules", PermissionRequestResultKind.DeniedByRules.Value); + Assert.Equal("denied-no-approval-rule-and-could-not-request-from-user", PermissionRequestResultKind.DeniedCouldNotRequestFromUser.Value); + Assert.Equal("denied-interactively-by-user", PermissionRequestResultKind.DeniedInteractivelyByUser.Value); + } + + [Fact] + public void Equals_SameValue_ReturnsTrue() + { + var a = new PermissionRequestResultKind("approved"); + Assert.True(a == PermissionRequestResultKind.Approved); + Assert.True(a.Equals(PermissionRequestResultKind.Approved)); + Assert.True(a.Equals((object)PermissionRequestResultKind.Approved)); + } + + [Fact] + public void Equals_DifferentValue_ReturnsFalse() + { + Assert.True(PermissionRequestResultKind.Approved != PermissionRequestResultKind.DeniedByRules); + Assert.False(PermissionRequestResultKind.Approved.Equals(PermissionRequestResultKind.DeniedByRules)); + } + + [Fact] + public void Equals_IsCaseInsensitive() + { + var upper = new PermissionRequestResultKind("APPROVED"); + Assert.Equal(PermissionRequestResultKind.Approved, upper); + } + + [Fact] + public void GetHashCode_IsCaseInsensitive() + { + var upper = new PermissionRequestResultKind("APPROVED"); + Assert.Equal(PermissionRequestResultKind.Approved.GetHashCode(), upper.GetHashCode()); + } + + [Fact] + public void ToString_ReturnsValue() + { + Assert.Equal("approved", PermissionRequestResultKind.Approved.ToString()); + Assert.Equal("denied-by-rules", PermissionRequestResultKind.DeniedByRules.ToString()); + } + + [Fact] + public void CustomValue_IsPreserved() + { + var custom = new PermissionRequestResultKind("custom-kind"); + Assert.Equal("custom-kind", custom.Value); + Assert.Equal("custom-kind", custom.ToString()); + } + + [Fact] + public void Constructor_NullValue_TreatedAsEmpty() + { + var kind = new PermissionRequestResultKind(null!); + Assert.Equal(string.Empty, kind.Value); + } + + [Fact] + public void Default_HasEmptyStringValue() + { + var defaultKind = default(PermissionRequestResultKind); + Assert.Equal(string.Empty, defaultKind.Value); + Assert.Equal(string.Empty, defaultKind.ToString()); + Assert.Equal(defaultKind.GetHashCode(), defaultKind.GetHashCode()); + } + + [Fact] + public void Equals_NonPermissionRequestResultKindObject_ReturnsFalse() + { + Assert.False(PermissionRequestResultKind.Approved.Equals("approved")); + } + + [Fact] + public void JsonSerialize_WritesStringValue() + { + var result = new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }; + var json = JsonSerializer.Serialize(result, s_jsonOptions); + Assert.Contains("\"kind\":\"approved\"", json); + } + + [Fact] + public void JsonDeserialize_ReadsStringValue() + { + var json = """{"kind":"denied-by-rules"}"""; + var result = JsonSerializer.Deserialize(json, s_jsonOptions)!; + Assert.Equal(PermissionRequestResultKind.DeniedByRules, result.Kind); + } + + [Fact] + public void JsonRoundTrip_PreservesAllKinds() + { + var kinds = new[] + { + PermissionRequestResultKind.Approved, + PermissionRequestResultKind.DeniedByRules, + PermissionRequestResultKind.DeniedCouldNotRequestFromUser, + PermissionRequestResultKind.DeniedInteractivelyByUser, + }; + + foreach (var kind in kinds) + { + var result = new PermissionRequestResult { Kind = kind }; + var json = JsonSerializer.Serialize(result, s_jsonOptions); + var deserialized = JsonSerializer.Deserialize(json, s_jsonOptions)!; + Assert.Equal(kind, deserialized.Kind); + } + } + + [Fact] + public void JsonRoundTrip_CustomValue() + { + var result = new PermissionRequestResult { Kind = new PermissionRequestResultKind("custom") }; + var json = JsonSerializer.Serialize(result, s_jsonOptions); + var deserialized = JsonSerializer.Deserialize(json, s_jsonOptions)!; + Assert.Equal("custom", deserialized.Kind.Value); + } +} + +[System.Text.Json.Serialization.JsonSerializable(typeof(PermissionRequestResult))] +internal partial class TestJsonContext : System.Text.Json.Serialization.JsonSerializerContext; diff --git a/dotnet/test/PermissionTests.cs b/dotnet/test/PermissionTests.cs index b1295be91..59a3cb4dd 100644 --- a/dotnet/test/PermissionTests.cs +++ b/dotnet/test/PermissionTests.cs @@ -15,13 +15,13 @@ public async Task Should_Invoke_Permission_Handler_For_Write_Operations() { var permissionRequests = new List(); CopilotSession? session = null; - session = await Client.CreateSessionAsync(new SessionConfig + session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = (request, invocation) => { permissionRequests.Add(request); Assert.Equal(session!.SessionId, invocation.SessionId); - return Task.FromResult(new PermissionRequestResult { Kind = "approved" }); + return Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }); } }); @@ -44,13 +44,13 @@ await session.SendAsync(new MessageOptions [Fact] public async Task Should_Deny_Permission_When_Handler_Returns_Denied() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = (request, invocation) => { return Task.FromResult(new PermissionRequestResult { - Kind = "denied-interactively-by-user" + Kind = PermissionRequestResultKind.DeniedInteractivelyByUser }); } }); @@ -71,9 +71,13 @@ await session.SendAsync(new MessageOptions } [Fact] - public async Task Should_Deny_Tool_Operations_By_Default_When_No_Handler_Is_Provided() + public async Task Should_Deny_Tool_Operations_When_Handler_Explicitly_Denies() { - var session = await Client.CreateSessionAsync(new SessionConfig()); + var session = await CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = (_, _) => + Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser }) + }); var permissionDenied = false; session.On(evt => @@ -95,10 +99,9 @@ await session.SendAndWaitAsync(new MessageOptions } [Fact] - public async Task Should_Work_Without_Permission_Handler__Default_Behavior_() + public async Task Should_Work_With_Approve_All_Permission_Handler() { - // Create session without permission handler - var session = await Client.CreateSessionAsync(new SessionConfig()); + var session = await CreateSessionAsync(new SessionConfig()); await session.SendAsync(new MessageOptions { @@ -113,14 +116,14 @@ await session.SendAsync(new MessageOptions public async Task Should_Handle_Async_Permission_Handler() { var permissionRequestReceived = false; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = async (request, invocation) => { permissionRequestReceived = true; // Simulate async permission check await Task.Delay(10); - return new PermissionRequestResult { Kind = "approved" }; + return new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }; } }); @@ -140,17 +143,17 @@ public async Task Should_Resume_Session_With_Permission_Handler() var permissionRequestReceived = false; // Create session without permission handler - var session1 = await Client.CreateSessionAsync(); + var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); // Resume with permission handler - var session2 = await Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { OnPermissionRequest = (request, invocation) => { permissionRequestReceived = true; - return Task.FromResult(new PermissionRequestResult { Kind = "approved" }); + return Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }); } }); @@ -165,7 +168,7 @@ await session2.SendAndWaitAsync(new MessageOptions [Fact] public async Task Should_Handle_Permission_Handler_Errors_Gracefully() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = (request, invocation) => { @@ -186,16 +189,20 @@ await session.SendAsync(new MessageOptions } [Fact] - public async Task Should_Deny_Tool_Operations_By_Default_When_No_Handler_Is_Provided_After_Resume() + public async Task Should_Deny_Tool_Operations_When_Handler_Explicitly_Denies_After_Resume() { - var session1 = await Client.CreateSessionAsync(new SessionConfig + var session1 = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); var sessionId = session1.SessionId; await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); - var session2 = await Client.ResumeSessionAsync(sessionId); + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig + { + OnPermissionRequest = (_, _) => + Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser }) + }); var permissionDenied = false; session2.On(evt => @@ -220,7 +227,7 @@ await session2.SendAndWaitAsync(new MessageOptions public async Task Should_Receive_ToolCallId_In_Permission_Requests() { var receivedToolCallId = false; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = (request, invocation) => { @@ -228,7 +235,7 @@ public async Task Should_Receive_ToolCallId_In_Permission_Requests() { receivedToolCallId = true; } - return Task.FromResult(new PermissionRequestResult { Kind = "approved" }); + return Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }); } }); diff --git a/dotnet/test/RpcTests.cs b/dotnet/test/RpcTests.cs index 818bc8760..a13695589 100644 --- a/dotnet/test/RpcTests.cs +++ b/dotnet/test/RpcTests.cs @@ -55,7 +55,7 @@ public async Task Should_Call_Rpc_Account_GetQuota_When_Authenticated() [Fact(Skip = "session.model.getCurrent not yet implemented in CLI")] public async Task Should_Call_Session_Rpc_Model_GetCurrent() { - var session = await Client.CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); + var session = await CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); var result = await session.Rpc.Model.GetCurrentAsync(); Assert.NotNull(result.ModelId); @@ -66,7 +66,7 @@ public async Task Should_Call_Session_Rpc_Model_GetCurrent() [Fact(Skip = "session.model.switchTo not yet implemented in CLI")] public async Task Should_Call_Session_Rpc_Model_SwitchTo() { - var session = await Client.CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); + var session = await CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); // Get initial model var before = await session.Rpc.Model.GetCurrentAsync(); @@ -84,7 +84,7 @@ public async Task Should_Call_Session_Rpc_Model_SwitchTo() [Fact] public async Task Should_Get_And_Set_Session_Mode() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); // Get initial mode (default should be interactive) var initial = await session.Rpc.Mode.GetAsync(); @@ -106,7 +106,7 @@ public async Task Should_Get_And_Set_Session_Mode() [Fact] public async Task Should_Read_Update_And_Delete_Plan() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); // Initially plan should not exist var initial = await session.Rpc.Plan.ReadAsync(); @@ -134,7 +134,7 @@ public async Task Should_Read_Update_And_Delete_Plan() [Fact] public async Task Should_Create_List_And_Read_Workspace_Files() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); // Initially no files var initialFiles = await session.Rpc.Workspace.ListFilesAsync(); diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index c9a152ce9..e710835dc 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -13,9 +13,9 @@ namespace GitHub.Copilot.SDK.Test; public class SessionTests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "session", output) { [Fact] - public async Task ShouldCreateAndDestroySessions() + public async Task ShouldCreateAndDisconnectSessions() { - var session = await Client.CreateSessionAsync(new SessionConfig { Model = "fake-test-model" }); + var session = await CreateSessionAsync(new SessionConfig { Model = "fake-test-model" }); Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); @@ -33,7 +33,7 @@ public async Task ShouldCreateAndDestroySessions() [Fact] public async Task Should_Have_Stateful_Conversation() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); var assistantMessage = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); Assert.NotNull(assistantMessage); @@ -48,7 +48,7 @@ public async Task Should_Have_Stateful_Conversation() public async Task Should_Create_A_Session_With_Appended_SystemMessage_Config() { var systemMessageSuffix = "End each response with the phrase 'Have a nice day!'"; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { SystemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Append, Content = systemMessageSuffix } }); @@ -72,7 +72,7 @@ public async Task Should_Create_A_Session_With_Appended_SystemMessage_Config() public async Task Should_Create_A_Session_With_Replaced_SystemMessage_Config() { var testSystemMessage = "You are an assistant called Testy McTestface. Reply succinctly."; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { SystemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Replace, Content = testSystemMessage } }); @@ -93,9 +93,9 @@ public async Task Should_Create_A_Session_With_Replaced_SystemMessage_Config() [Fact] public async Task Should_Create_A_Session_With_AvailableTools() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { - AvailableTools = new List { "view", "edit" } + AvailableTools = ["view", "edit"] }); await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); @@ -113,9 +113,9 @@ public async Task Should_Create_A_Session_With_AvailableTools() [Fact] public async Task Should_Create_A_Session_With_ExcludedTools() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { - ExcludedTools = new List { "view" } + ExcludedTools = ["view"] }); await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); @@ -133,7 +133,7 @@ public async Task Should_Create_A_Session_With_ExcludedTools() [Fact] public async Task Should_Create_Session_With_Custom_Tool() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { Tools = [ @@ -153,7 +153,7 @@ public async Task Should_Create_Session_With_Custom_Tool() [Fact] public async Task Should_Resume_A_Session_Using_The_Same_Client() { - var session1 = await Client.CreateSessionAsync(); + var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; await session1.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); @@ -161,18 +161,23 @@ public async Task Should_Resume_A_Session_Using_The_Same_Client() Assert.NotNull(answer); Assert.Contains("2", answer!.Data.Content ?? string.Empty); - var session2 = await Client.ResumeSessionAsync(sessionId); + var session2 = await ResumeSessionAsync(sessionId); Assert.Equal(sessionId, session2.SessionId); var answer2 = await TestHelper.GetFinalAssistantMessageAsync(session2); Assert.NotNull(answer2); Assert.Contains("2", answer2!.Data.Content ?? string.Empty); + + // Can continue the conversation statefully + var answer3 = await session2.SendAndWaitAsync(new MessageOptions { Prompt = "Now if you double that, what do you get?" }); + Assert.NotNull(answer3); + Assert.Contains("4", answer3!.Data.Content ?? string.Empty); } [Fact] public async Task Should_Resume_A_Session_Using_A_New_Client() { - var session1 = await Client.CreateSessionAsync(); + var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; await session1.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); @@ -181,25 +186,30 @@ public async Task Should_Resume_A_Session_Using_A_New_Client() Assert.Contains("2", answer!.Data.Content ?? string.Empty); using var newClient = Ctx.CreateClient(); - var session2 = await newClient.ResumeSessionAsync(sessionId); + var session2 = await newClient.ResumeSessionAsync(sessionId, new ResumeSessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); Assert.Equal(sessionId, session2.SessionId); var messages = await session2.GetMessagesAsync(); Assert.Contains(messages, m => m is UserMessageEvent); Assert.Contains(messages, m => m is SessionResumeEvent); + + // Can continue the conversation statefully + var answer2 = await session2.SendAndWaitAsync(new MessageOptions { Prompt = "Now if you double that, what do you get?" }); + Assert.NotNull(answer2); + Assert.Contains("4", answer2!.Data.Content ?? string.Empty); } [Fact] public async Task Should_Throw_Error_When_Resuming_Non_Existent_Session() { await Assert.ThrowsAsync(() => - Client.ResumeSessionAsync("non-existent-session-id")); + ResumeSessionAsync("non-existent-session-id")); } [Fact] public async Task Should_Abort_A_Session() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); // Set up wait for tool execution to start BEFORE sending var toolStartTask = TestHelper.GetNextEventOfTypeAsync(session); @@ -231,72 +241,10 @@ await session.SendAsync(new MessageOptions Assert.Contains("4", answer!.Data.Content ?? string.Empty); } - // TODO: This test requires the session-events.schema.json to include assistant.message_delta. - // The CLI v0.0.376 emits delta events at runtime, but the schema hasn't been updated yet. - // Once the schema is updated and types are regenerated, this test can be enabled. - [Fact(Skip = "Requires schema update for AssistantMessageDeltaEvent type")] - public async Task Should_Receive_Streaming_Delta_Events_When_Streaming_Is_Enabled() - { - var session = await Client.CreateSessionAsync(new SessionConfig { Streaming = true }); - - var deltaContents = new List(); - var doneEvent = new TaskCompletionSource(); - - session.On(evt => - { - switch (evt) - { - // TODO: Uncomment once AssistantMessageDeltaEvent is generated - // case AssistantMessageDeltaEvent delta: - // if (!string.IsNullOrEmpty(delta.Data.DeltaContent)) - // deltaContents.Add(delta.Data.DeltaContent); - // break; - case SessionIdleEvent: - doneEvent.TrySetResult(true); - break; - } - }); - - await session.SendAsync(new MessageOptions { Prompt = "What is 2+2?" }); - - // Wait for completion - var completed = await Task.WhenAny(doneEvent.Task, Task.Delay(TimeSpan.FromSeconds(60))); - Assert.Equal(doneEvent.Task, completed); - - // Should have received delta events - Assert.NotEmpty(deltaContents); - - // Get the final message to compare - var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); - Assert.NotNull(assistantMessage); - - // Accumulated deltas should equal the final message - var accumulated = string.Join("", deltaContents); - Assert.Equal(assistantMessage!.Data.Content, accumulated); - - // Final message should contain the answer - Assert.Contains("4", assistantMessage.Data.Content ?? string.Empty); - } - - [Fact] - public async Task Should_Pass_Streaming_Option_To_Session_Creation() - { - // Verify that the streaming option is accepted without errors - var session = await Client.CreateSessionAsync(new SessionConfig { Streaming = true }); - - Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); - - // Session should still work normally - await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); - var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); - Assert.NotNull(assistantMessage); - Assert.Contains("2", assistantMessage!.Data.Content); - } - [Fact] public async Task Should_Receive_Session_Events() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); var receivedEvents = new List(); var idleReceived = new TaskCompletionSource(); @@ -333,7 +281,7 @@ public async Task Should_Receive_Session_Events() [Fact] public async Task Send_Returns_Immediately_While_Events_Stream_In_Background() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, }); @@ -358,7 +306,7 @@ public async Task Send_Returns_Immediately_While_Events_Stream_In_Background() [Fact] public async Task SendAndWait_Blocks_Until_Session_Idle_And_Returns_Final_Assistant_Message() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); var events = new List(); session.On(evt => events.Add(evt.Type)); @@ -376,7 +324,7 @@ public async Task SendAndWait_Blocks_Until_Session_Idle_And_Returns_Final_Assist [Fact(Skip = "Needs test harness CAPI proxy support")] public async Task Should_List_Sessions_With_Context() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); var sessions = await Client.ListSessionsAsync(); Assert.NotEmpty(sessions); @@ -394,7 +342,7 @@ public async Task Should_List_Sessions_With_Context() [Fact] public async Task SendAndWait_Throws_On_Timeout() { - var session = await Client.CreateSessionAsync(); + var session = await CreateSessionAsync(); // Use a slow command to ensure timeout triggers before completion var ex = await Assert.ThrowsAsync(() => @@ -403,11 +351,35 @@ public async Task SendAndWait_Throws_On_Timeout() Assert.Contains("timed out", ex.Message); } + [Fact] + public async Task SendAndWait_Throws_OperationCanceledException_When_Token_Cancelled() + { + var session = await CreateSessionAsync(); + + // Set up wait for tool execution to start BEFORE sending + var toolStartTask = TestHelper.GetNextEventOfTypeAsync(session); + + using var cts = new CancellationTokenSource(); + + // Start SendAndWaitAsync - don't await it yet + var sendTask = session.SendAndWaitAsync( + new MessageOptions { Prompt = "run the shell command 'sleep 10' (note this works on both bash and PowerShell)" }, + cancellationToken: cts.Token); + + // Wait for the tool to begin executing before cancelling + await toolStartTask; + + // Cancel the token + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => sendTask); + } + [Fact] public async Task Should_Create_Session_With_Custom_Config_Dir() { var customConfigDir = Path.Join(Ctx.HomeDir, "custom-config"); - var session = await Client.CreateSessionAsync(new SessionConfig { ConfigDir = customConfigDir }); + var session = await CreateSessionAsync(new SessionConfig { ConfigDir = customConfigDir }); Assert.Matches(@"^[a-f0-9-]+$", session.SessionId); @@ -417,4 +389,19 @@ public async Task Should_Create_Session_With_Custom_Config_Dir() Assert.NotNull(assistantMessage); Assert.Contains("2", assistantMessage!.Data.Content); } + + [Fact] + public async Task Should_Set_Model_On_Existing_Session() + { + var session = await CreateSessionAsync(); + + // Subscribe for the model change event before calling SetModelAsync + var modelChangedTask = TestHelper.GetNextEventOfTypeAsync(session); + + await session.SetModelAsync("gpt-4.1"); + + // Verify a model_change event was emitted with the new model + var modelChanged = await modelChangedTask; + Assert.Equal("gpt-4.1", modelChanged.Data.NewModel); + } } diff --git a/dotnet/test/SkillsTests.cs b/dotnet/test/SkillsTests.cs index bba5e1e5f..d68eed79d 100644 --- a/dotnet/test/SkillsTests.cs +++ b/dotnet/test/SkillsTests.cs @@ -52,7 +52,7 @@ private string CreateSkillDir() public async Task Should_Load_And_Apply_Skill_From_SkillDirectories() { var skillsDir = CreateSkillDir(); - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { SkillDirectories = [skillsDir] }); @@ -71,7 +71,7 @@ public async Task Should_Load_And_Apply_Skill_From_SkillDirectories() public async Task Should_Not_Apply_Skill_When_Disabled_Via_DisabledSkills() { var skillsDir = CreateSkillDir(); - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { SkillDirectories = [skillsDir], DisabledSkills = ["test-skill"] @@ -93,7 +93,7 @@ public async Task Should_Apply_Skill_On_Session_Resume_With_SkillDirectories() var skillsDir = CreateSkillDir(); // Create a session without skills first - var session1 = await Client.CreateSessionAsync(); + var session1 = await CreateSessionAsync(); var sessionId = session1.SessionId; // First message without skill - marker should not appear @@ -102,7 +102,7 @@ public async Task Should_Apply_Skill_On_Session_Resume_With_SkillDirectories() Assert.DoesNotContain(SkillMarker, message1!.Data.Content); // Resume with skillDirectories - skill should now be active - var session2 = await Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig { SkillDirectories = [skillsDir] }); diff --git a/dotnet/test/StreamingFidelityTests.cs b/dotnet/test/StreamingFidelityTests.cs new file mode 100644 index 000000000..c38cb1545 --- /dev/null +++ b/dotnet/test/StreamingFidelityTests.cs @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.SDK.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.SDK.Test; + +public class StreamingFidelityTests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "streaming_fidelity", output) +{ + [Fact] + public async Task Should_Produce_Delta_Events_When_Streaming_Is_Enabled() + { + var session = await CreateSessionAsync(new SessionConfig { Streaming = true }); + + var events = new List(); + session.On(evt => events.Add(evt)); + + await session.SendAndWaitAsync(new MessageOptions { Prompt = "Count from 1 to 5, separated by commas." }); + + var types = events.Select(e => e.Type).ToList(); + + // Should have streaming deltas before the final message + var deltaEvents = events.OfType().ToList(); + Assert.NotEmpty(deltaEvents); + + // Deltas should have content + foreach (var delta in deltaEvents) + { + Assert.False(string.IsNullOrEmpty(delta.Data.DeltaContent)); + } + + // Should still have a final assistant.message + Assert.Contains("assistant.message", types); + + // Deltas should come before the final message + var firstDeltaIdx = types.IndexOf("assistant.message_delta"); + var lastAssistantIdx = types.LastIndexOf("assistant.message"); + Assert.True(firstDeltaIdx < lastAssistantIdx); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Should_Not_Produce_Deltas_When_Streaming_Is_Disabled() + { + var session = await CreateSessionAsync(new SessionConfig { Streaming = false }); + + var events = new List(); + session.On(evt => events.Add(evt)); + + await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say 'hello world'." }); + + var deltaEvents = events.OfType().ToList(); + + // No deltas when streaming is off + Assert.Empty(deltaEvents); + + // But should still have a final assistant.message + var assistantEvents = events.OfType().ToList(); + Assert.NotEmpty(assistantEvents); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Should_Produce_Deltas_After_Session_Resume() + { + var session = await CreateSessionAsync(new SessionConfig { Streaming = false }); + await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 3 + 6?" }); + await session.DisposeAsync(); + + // Resume using a new client + using var newClient = Ctx.CreateClient(); + var session2 = await newClient.ResumeSessionAsync(session.SessionId, + new ResumeSessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, Streaming = true }); + + var events = new List(); + session2.On(evt => events.Add(evt)); + + var answer = await session2.SendAndWaitAsync(new MessageOptions { Prompt = "Now if you double that, what do you get?" }); + Assert.NotNull(answer); + Assert.Contains("18", answer!.Data.Content ?? string.Empty); + + // Should have streaming deltas before the final message + var deltaEvents = events.OfType().ToList(); + Assert.NotEmpty(deltaEvents); + + // Deltas should have content + foreach (var delta in deltaEvents) + { + Assert.False(string.IsNullOrEmpty(delta.Data.DeltaContent)); + } + + await session2.DisposeAsync(); + } +} diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index ad1ab7a21..b31ef1f93 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -4,7 +4,9 @@ using GitHub.Copilot.SDK.Test.Harness; using Microsoft.Extensions.AI; +using System.Collections.ObjectModel; using System.ComponentModel; +using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using Xunit; @@ -21,7 +23,7 @@ await File.WriteAllTextAsync( Path.Combine(Ctx.WorkDir, "README.md"), "# ELIZA, the only chatbot you'll ever need"); - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, }); @@ -39,9 +41,10 @@ await session.SendAsync(new MessageOptions [Fact] public async Task Invokes_Custom_Tool() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { Tools = [AIFunctionFactory.Create(EncryptString, "encrypt_string")], + OnPermissionRequest = PermissionHandler.ApproveAll, }); await session.SendAsync(new MessageOptions @@ -64,9 +67,10 @@ public async Task Handles_Tool_Calling_Errors() var getUserLocation = AIFunctionFactory.Create( () => { throw new Exception("Melbourne"); }, "get_user_location", "Gets the user's location"); - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { - Tools = [getUserLocation] + Tools = [getUserLocation], + OnPermissionRequest = PermissionHandler.ApproveAll, }); await session.SendAsync(new MessageOptions { Prompt = "What is my location? If you can't find out, just say 'unknown'." }); @@ -105,9 +109,10 @@ public async Task Handles_Tool_Calling_Errors() public async Task Can_Receive_And_Return_Complex_Types() { ToolInvocation? receivedInvocation = null; - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { Tools = [AIFunctionFactory.Create(PerformDbQuery, "db_query", serializerOptions: ToolsTestsJsonContext.Default.Options)], + OnPermissionRequest = PermissionHandler.ApproveAll, }); await session.SendAsync(new MessageOptions @@ -132,7 +137,7 @@ await session.SendAsync(new MessageOptions City[] PerformDbQuery(DbQueryOptions query, AIFunctionArguments rawArgs) { Assert.Equal("cities", query.Table); - Assert.Equal(new[] { 12, 19 }, query.Ids); + Assert.Equal([12, 19], query.Ids); Assert.True(query.SortAscending); receivedInvocation = (ToolInvocation)rawArgs.Context![typeof(ToolInvocation)]!; return [new(19, "Passos", 135460), new(12, "San Lorenzo", 204356)]; @@ -148,12 +153,41 @@ record City(int CountryId, string CityName, int Population); [JsonSerializable(typeof(JsonElement))] private partial class ToolsTestsJsonContext : JsonSerializerContext; + [Fact] + public async Task Overrides_Built_In_Tool_With_Custom_Tool() + { + var session = await CreateSessionAsync(new SessionConfig + { + Tools = [AIFunctionFactory.Create((Delegate)CustomGrep, new AIFunctionFactoryOptions + { + Name = "grep", + AdditionalProperties = new ReadOnlyDictionary( + new Dictionary { ["is_override"] = true }) + })], + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + await session.SendAsync(new MessageOptions + { + Prompt = "Use grep to search for the word 'hello'" + }); + + var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + Assert.NotNull(assistantMessage); + Assert.Contains("CUSTOM_GREP_RESULT", assistantMessage!.Data.Content ?? string.Empty); + + [Description("A custom grep implementation that overrides the built-in")] + static string CustomGrep([Description("Search query")] string query) + => $"CUSTOM_GREP_RESULT: {query}"; + } + [Fact(Skip = "Behaves as if no content was in the result. Likely that binary results aren't fully implemented yet.")] public async Task Can_Return_Binary_Result() { - var session = await Client.CreateSessionAsync(new SessionConfig + var session = await CreateSessionAsync(new SessionConfig { Tools = [AIFunctionFactory.Create(GetImage, "get_image")], + OnPermissionRequest = PermissionHandler.ApproveAll, }); await session.SendAsync(new MessageOptions @@ -166,7 +200,7 @@ await session.SendAsync(new MessageOptions Assert.Contains("yellow", assistantMessage!.Data.Content?.ToLowerInvariant() ?? string.Empty); - static ToolResultAIContent GetImage() => new ToolResultAIContent(new() + static ToolResultAIContent GetImage() => new(new() { BinaryResultsForLlm = [new() { // 2x2 yellow square @@ -177,4 +211,69 @@ await session.SendAsync(new MessageOptions SessionLog = "Returned an image", }); } + + [Fact] + public async Task Invokes_Custom_Tool_With_Permission_Handler() + { + var permissionRequests = new List(); + + var session = await Client.CreateSessionAsync(new SessionConfig + { + Tools = [AIFunctionFactory.Create(EncryptStringForPermission, "encrypt_string")], + OnPermissionRequest = (request, invocation) => + { + permissionRequests.Add(request); + return Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }); + }, + }); + + await session.SendAsync(new MessageOptions + { + Prompt = "Use encrypt_string to encrypt this string: Hello" + }); + + var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + Assert.NotNull(assistantMessage); + Assert.Contains("HELLO", assistantMessage!.Data.Content ?? string.Empty); + + // Should have received a custom-tool permission request with the correct tool name + var customToolRequest = permissionRequests.FirstOrDefault(r => r.Kind == "custom-tool"); + Assert.NotNull(customToolRequest); + Assert.True(customToolRequest!.ExtensionData?.ContainsKey("toolName") ?? false); + var toolName = ((JsonElement)customToolRequest.ExtensionData!["toolName"]).GetString(); + Assert.Equal("encrypt_string", toolName); + + [Description("Encrypts a string")] + static string EncryptStringForPermission([Description("String to encrypt")] string input) + => input.ToUpperInvariant(); + } + + [Fact] + public async Task Denies_Custom_Tool_When_Permission_Denied() + { + var toolHandlerCalled = false; + + var session = await Client.CreateSessionAsync(new SessionConfig + { + Tools = [AIFunctionFactory.Create(EncryptStringDenied, "encrypt_string")], + OnPermissionRequest = async (request, invocation) => new() { Kind = PermissionRequestResultKind.DeniedInteractivelyByUser }, + }); + + await session.SendAsync(new MessageOptions + { + Prompt = "Use encrypt_string to encrypt this string: Hello" + }); + + await TestHelper.GetFinalAssistantMessageAsync(session); + + // The tool handler should NOT have been called since permission was denied + Assert.False(toolHandlerCalled); + + [Description("Encrypts a string")] + string EncryptStringDenied([Description("String to encrypt")] string input) + { + toolHandlerCalled = true; + return input.ToUpperInvariant(); + } + } } diff --git a/go/README.md b/go/README.md index 37cb7ce07..4cc73398c 100644 --- a/go/README.md +++ b/go/README.md @@ -25,6 +25,7 @@ go run chat.go package main import ( + "context" "fmt" "log" @@ -50,7 +51,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() // Set up event handler done := make(chan bool) @@ -99,10 +100,11 @@ That's it! When your application calls `copilot.NewClient` without a `CLIPath` n - `Stop() error` - Stop the CLI server - `ForceStop()` - Forcefully stop without graceful cleanup - `CreateSession(config *SessionConfig) (*Session, error)` - Create a new session -- `ResumeSession(sessionID string) (*Session, error)` - Resume an existing session +- `ResumeSession(sessionID string, config *ResumeSessionConfig) (*Session, error)` - Resume an existing session - `ResumeSessionWithOptions(sessionID string, config *ResumeSessionConfig) (*Session, error)` - Resume with additional configuration - `ListSessions(filter *SessionListFilter) ([]SessionMetadata, error)` - List sessions (with optional filter) - `DeleteSession(sessionID string) error` - Delete a session permanently +- `GetLastSessionID(ctx context.Context) (*string, error)` - Get the ID of the most recently updated session - `GetState() ConnectionState` - Get connection state - `Ping(message string) (*PingResponse, error)` - Ping the server - `GetForegroundSessionID(ctx context.Context) (*string, error)` - Get the session ID currently displayed in TUI (TUI+server mode only) @@ -138,8 +140,8 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `AutoStart` (\*bool): Auto-start server on first use (default: true). Use `Bool(false)` to disable. - `AutoRestart` (\*bool): Auto-restart on crash (default: true). Use `Bool(false)` to disable. - `Env` ([]string): Environment variables for CLI process (default: inherits from current process) -- `GithubToken` (string): GitHub token for authentication. When provided, takes priority over other auth methods. -- `UseLoggedInUser` (\*bool): Whether to use logged-in user for authentication (default: true, but false when `GithubToken` is provided). Cannot be used with `CLIUrl`. +- `GitHubToken` (string): GitHub token for authentication. When provided, takes priority over other auth methods. +- `UseLoggedInUser` (\*bool): Whether to use logged-in user for authentication (default: true, but false when `GitHubToken` is provided). Cannot be used with `CLIUrl`. **SessionConfig:** @@ -167,7 +169,8 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `On(handler SessionEventHandler) func()` - Subscribe to events (returns unsubscribe function) - `Abort(ctx context.Context) error` - Abort the currently processing message - `GetMessages(ctx context.Context) ([]SessionEvent, error)` - Get message history -- `Destroy() error` - Destroy the session +- `Disconnect() error` - Disconnect the session (releases in-memory resources, preserves disk state) +- `Destroy() error` - *(Deprecated)* Use `Disconnect()` instead ### Helper Functions @@ -266,6 +269,18 @@ session, _ := client.CreateSession(context.Background(), &copilot.SessionConfig{ When the model selects a tool, the SDK automatically runs your handler (in parallel with other calls) and responds to the CLI's `tool.call` with the handler's result. +#### Overriding Built-in Tools + +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), the SDK will throw an error unless you explicitly opt in by setting `OverridesBuiltInTool = true`. This flag signals that you intend to replace the built-in tool with your custom implementation. + +```go +editFile := copilot.DefineTool("edit_file", "Custom file editor with project-specific validation", + func(params EditFileParams, inv copilot.ToolInvocation) (any, error) { + // your logic + }) +editFile.OverridesBuiltInTool = true +``` + ## Streaming Enable streaming to receive assistant response chunks as they're generated: @@ -274,6 +289,7 @@ Enable streaming to receive assistant response chunks as they're generated: package main import ( + "context" "fmt" "log" @@ -295,7 +311,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() done := make(chan bool) diff --git a/go/client.go b/go/client.go index 68f58d859..a43530adb 100644 --- a/go/client.go +++ b/go/client.go @@ -12,6 +12,7 @@ // defer client.Stop() // // session, err := client.CreateSession(&copilot.SessionConfig{ +// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, // Model: "gpt-4", // }) // if err != nil { @@ -40,6 +41,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/github/copilot-sdk/go/internal/embeddedcli" @@ -67,26 +69,29 @@ import ( // } // defer client.Stop() type Client struct { - options ClientOptions - process *exec.Cmd - client *jsonrpc2.Client - actualPort int - actualHost string - state ConnectionState - sessions map[string]*Session - sessionsMux sync.Mutex - isExternalServer bool - conn net.Conn // stores net.Conn for external TCP connections - useStdio bool // resolved value from options - autoStart bool // resolved value from options - autoRestart bool // resolved value from options - modelsCache []ModelInfo - modelsCacheMux sync.Mutex - lifecycleHandlers []SessionLifecycleHandler - typedLifecycleHandlers map[SessionLifecycleEventType][]SessionLifecycleHandler - lifecycleHandlersMux sync.Mutex - processDone chan struct{} // closed when CLI process exits - processError error // set before processDone is closed + options ClientOptions + process *exec.Cmd + client *jsonrpc2.Client + actualPort int + actualHost string + state ConnectionState + sessions map[string]*Session + sessionsMux sync.Mutex + isExternalServer bool + conn net.Conn // stores net.Conn for external TCP connections + useStdio bool // resolved value from options + autoStart bool // resolved value from options + autoRestart bool // resolved value from options + modelsCache []ModelInfo + modelsCacheMux sync.Mutex + lifecycleHandlers []SessionLifecycleHandler + typedLifecycleHandlers map[SessionLifecycleEventType][]SessionLifecycleHandler + lifecycleHandlersMux sync.Mutex + startStopMux sync.RWMutex // protects process and state during start/[force]stop + processDone chan struct{} + processErrorPtr *error + osProcess atomic.Pointer[os.Process] + negotiatedProtocolVersion int // RPC provides typed server-scoped RPC methods. // This field is nil until the client is connected via Start(). @@ -134,8 +139,8 @@ func NewClient(options *ClientOptions) *Client { } // Validate auth options with external server - if options.CLIUrl != "" && (options.GithubToken != "" || options.UseLoggedInUser != nil) { - panic("GithubToken and UseLoggedInUser cannot be used with CLIUrl (external server manages its own auth)") + if options.CLIUrl != "" && (options.GitHubToken != "" || options.UseLoggedInUser != nil) { + panic("GitHubToken and UseLoggedInUser cannot be used with CLIUrl (external server manages its own auth)") } // Parse CLIUrl if provided @@ -177,8 +182,8 @@ func NewClient(options *ClientOptions) *Client { if options.AutoRestart != nil { client.autoRestart = *options.AutoRestart } - if options.GithubToken != "" { - opts.GithubToken = options.GithubToken + if options.GitHubToken != "" { + opts.GitHubToken = options.GitHubToken } if options.UseLoggedInUser != nil { opts.UseLoggedInUser = options.UseLoggedInUser @@ -250,6 +255,9 @@ func parseCliUrl(url string) (string, int) { // } // // Now ready to create sessions func (c *Client) Start(ctx context.Context) error { + c.startStopMux.Lock() + defer c.startStopMux.Unlock() + if c.state == StateConnected { return nil } @@ -259,6 +267,7 @@ func (c *Client) Start(ctx context.Context) error { // Only start CLI server process if not connecting to external server if !c.isExternalServer { if err := c.startCLIServer(ctx); err != nil { + c.process = nil c.state = StateError return err } @@ -266,14 +275,16 @@ func (c *Client) Start(ctx context.Context) error { // Connect to the server if err := c.connectToServer(ctx); err != nil { + killErr := c.killProcess() c.state = StateError - return err + return errors.Join(err, killErr) } // Verify protocol version compatibility if err := c.verifyProtocolVersion(ctx); err != nil { + killErr := c.killProcess() c.state = StateError - return err + return errors.Join(err, killErr) } c.state = StateConnected @@ -283,10 +294,14 @@ func (c *Client) Start(ctx context.Context) error { // Stop stops the CLI server and closes all active sessions. // // This method performs graceful cleanup: -// 1. Destroys all active sessions +// 1. Closes all active sessions (releases in-memory resources) // 2. Closes the JSON-RPC connection // 3. Terminates the CLI server process (if spawned by this client) // +// Note: session data on disk is preserved, so sessions can be resumed later. +// To permanently remove session data before stopping, call [Client.DeleteSession] +// for each session first. +// // Returns an error that aggregates all errors encountered during cleanup. // // Example: @@ -297,7 +312,7 @@ func (c *Client) Start(ctx context.Context) error { func (c *Client) Stop() error { var errs []error - // Destroy all active sessions + // Disconnect all active sessions c.sessionsMux.Lock() sessions := make([]*Session, 0, len(c.sessions)) for _, session := range c.sessions { @@ -306,8 +321,8 @@ func (c *Client) Stop() error { c.sessionsMux.Unlock() for _, session := range sessions { - if err := session.Destroy(); err != nil { - errs = append(errs, fmt.Errorf("failed to destroy session %s: %w", session.SessionID, err)) + if err := session.Disconnect(); err != nil { + errs = append(errs, fmt.Errorf("failed to disconnect session %s: %w", session.SessionID, err)) } } @@ -315,13 +330,16 @@ func (c *Client) Stop() error { c.sessions = make(map[string]*Session) c.sessionsMux.Unlock() + c.startStopMux.Lock() + defer c.startStopMux.Unlock() + // Kill CLI process FIRST (this closes stdout and unblocks readLoop) - only if we spawned it if c.process != nil && !c.isExternalServer { - if err := c.process.Process.Kill(); err != nil { - errs = append(errs, fmt.Errorf("failed to kill CLI process: %w", err)) + if err := c.killProcess(); err != nil { + errs = append(errs, err) } - c.process = nil } + c.process = nil // Close external TCP connection if exists if c.isExternalServer && c.conn != nil { @@ -374,16 +392,27 @@ func (c *Client) Stop() error { // client.ForceStop() // } func (c *Client) ForceStop() { + // Kill the process without waiting for startStopMux, which Start may hold. + // This unblocks any I/O Start is doing (connect, version check). + if p := c.osProcess.Swap(nil); p != nil { + p.Kill() + } + // Clear sessions immediately without trying to destroy them c.sessionsMux.Lock() c.sessions = make(map[string]*Session) c.sessionsMux.Unlock() + c.startStopMux.Lock() + defer c.startStopMux.Unlock() + // Kill CLI process (only if we spawned it) + // This is a fallback in case the process wasn't killed above (e.g. if Start hadn't set + // osProcess yet), or if the process was restarted and osProcess now points to a new process. if c.process != nil && !c.isExternalServer { - c.process.Process.Kill() // Ignore errors - c.process = nil + _ = c.killProcess() // Ignore errors since we're force stopping } + c.process = nil // Close external TCP connection if exists if c.isExternalServer && c.conn != nil { @@ -426,17 +455,20 @@ func (c *Client) ensureConnected() error { // If the client is not connected and AutoStart is enabled, this will automatically // start the connection. // -// The config parameter is optional; pass nil for default settings. +// The config parameter is required and must include an OnPermissionRequest handler. // // Returns the created session or an error if session creation fails. // // Example: // // // Basic session -// session, err := client.CreateSession(context.Background(), nil) +// session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ +// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, +// }) // // // Session with model and tools // session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ +// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, // Model: "gpt-4", // Tools: []copilot.Tool{ // { @@ -447,44 +479,46 @@ func (c *Client) ensureConnected() error { // }, // }) func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Session, error) { + if config == nil || config.OnPermissionRequest == nil { + return nil, fmt.Errorf("an OnPermissionRequest handler is required when creating a session. For example, to allow all permissions, use &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}") + } + if err := c.ensureConnected(); err != nil { return nil, err } req := createSessionRequest{} - if config != nil { - req.Model = config.Model - req.SessionID = config.SessionID - req.ClientName = config.ClientName - req.ReasoningEffort = config.ReasoningEffort - req.ConfigDir = config.ConfigDir - req.Tools = config.Tools - req.SystemMessage = config.SystemMessage - req.AvailableTools = config.AvailableTools - req.ExcludedTools = config.ExcludedTools - req.Provider = config.Provider - req.WorkingDirectory = config.WorkingDirectory - req.MCPServers = config.MCPServers - req.EnvValueMode = "direct" - req.CustomAgents = config.CustomAgents - req.SkillDirectories = config.SkillDirectories - req.DisabledSkills = config.DisabledSkills - req.InfiniteSessions = config.InfiniteSessions - - if config.Streaming { - req.Streaming = Bool(true) - } - if config.OnUserInputRequest != nil { - req.RequestUserInput = Bool(true) - } - if config.Hooks != nil && (config.Hooks.OnPreToolUse != nil || - config.Hooks.OnPostToolUse != nil || - config.Hooks.OnUserPromptSubmitted != nil || - config.Hooks.OnSessionStart != nil || - config.Hooks.OnSessionEnd != nil || - config.Hooks.OnErrorOccurred != nil) { - req.Hooks = Bool(true) - } + req.Model = config.Model + req.SessionID = config.SessionID + req.ClientName = config.ClientName + req.ReasoningEffort = config.ReasoningEffort + req.ConfigDir = config.ConfigDir + req.Tools = config.Tools + req.SystemMessage = config.SystemMessage + req.AvailableTools = config.AvailableTools + req.ExcludedTools = config.ExcludedTools + req.Provider = config.Provider + req.WorkingDirectory = config.WorkingDirectory + req.MCPServers = config.MCPServers + req.EnvValueMode = "direct" + req.CustomAgents = config.CustomAgents + req.SkillDirectories = config.SkillDirectories + req.DisabledSkills = config.DisabledSkills + req.InfiniteSessions = config.InfiniteSessions + + if config.Streaming { + req.Streaming = Bool(true) + } + if config.OnUserInputRequest != nil { + req.RequestUserInput = Bool(true) + } + if config.Hooks != nil && (config.Hooks.OnPreToolUse != nil || + config.Hooks.OnPostToolUse != nil || + config.Hooks.OnUserPromptSubmitted != nil || + config.Hooks.OnSessionStart != nil || + config.Hooks.OnSessionEnd != nil || + config.Hooks.OnErrorOccurred != nil) { + req.Hooks = Bool(true) } req.RequestPermission = Bool(true) @@ -500,19 +534,13 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses session := newSession(response.SessionID, c.client, response.WorkspacePath) - if config != nil { - session.registerTools(config.Tools) - if config.OnPermissionRequest != nil { - session.registerPermissionHandler(config.OnPermissionRequest) - } - if config.OnUserInputRequest != nil { - session.registerUserInputHandler(config.OnUserInputRequest) - } - if config.Hooks != nil { - session.registerHooks(config.Hooks) - } - } else { - session.registerTools(nil) + session.registerTools(config.Tools) + session.registerPermissionHandler(config.OnPermissionRequest) + if config.OnUserInputRequest != nil { + session.registerUserInputHandler(config.OnUserInputRequest) + } + if config.Hooks != nil { + session.registerHooks(config.Hooks) } c.sessionsMux.Lock() @@ -522,15 +550,18 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses return session, nil } -// ResumeSession resumes an existing conversation session by its ID using default options. +// ResumeSession resumes an existing conversation session by its ID. // -// This is a convenience method that calls [Client.ResumeSessionWithOptions] with nil config. +// This is a convenience method that calls [Client.ResumeSessionWithOptions]. +// The config must include an OnPermissionRequest handler. // // Example: // -// session, err := client.ResumeSession(context.Background(), "session-123") -func (c *Client) ResumeSession(ctx context.Context, sessionID string) (*Session, error) { - return c.ResumeSessionWithOptions(ctx, sessionID, nil) +// session, err := client.ResumeSession(context.Background(), "session-123", &copilot.ResumeSessionConfig{ +// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, +// }) +func (c *Client) ResumeSession(ctx context.Context, sessionID string, config *ResumeSessionConfig) (*Session, error) { + return c.ResumeSessionWithOptions(ctx, sessionID, config) } // ResumeSessionWithOptions resumes an existing conversation session with additional configuration. @@ -541,50 +572,53 @@ func (c *Client) ResumeSession(ctx context.Context, sessionID string) (*Session, // Example: // // session, err := client.ResumeSessionWithOptions(context.Background(), "session-123", &copilot.ResumeSessionConfig{ +// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, // Tools: []copilot.Tool{myNewTool}, // }) func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, config *ResumeSessionConfig) (*Session, error) { + if config == nil || config.OnPermissionRequest == nil { + return nil, fmt.Errorf("an OnPermissionRequest handler is required when resuming a session. For example, to allow all permissions, use &copilot.ResumeSessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}") + } + if err := c.ensureConnected(); err != nil { return nil, err } var req resumeSessionRequest req.SessionID = sessionID - if config != nil { - req.ClientName = config.ClientName - req.Model = config.Model - req.ReasoningEffort = config.ReasoningEffort - req.SystemMessage = config.SystemMessage - req.Tools = config.Tools - req.Provider = config.Provider - req.AvailableTools = config.AvailableTools - req.ExcludedTools = config.ExcludedTools - if config.Streaming { - req.Streaming = Bool(true) - } - if config.OnUserInputRequest != nil { - req.RequestUserInput = Bool(true) - } - if config.Hooks != nil && (config.Hooks.OnPreToolUse != nil || - config.Hooks.OnPostToolUse != nil || - config.Hooks.OnUserPromptSubmitted != nil || - config.Hooks.OnSessionStart != nil || - config.Hooks.OnSessionEnd != nil || - config.Hooks.OnErrorOccurred != nil) { - req.Hooks = Bool(true) - } - req.WorkingDirectory = config.WorkingDirectory - req.ConfigDir = config.ConfigDir - if config.DisableResume { - req.DisableResume = Bool(true) - } - req.MCPServers = config.MCPServers - req.EnvValueMode = "direct" - req.CustomAgents = config.CustomAgents - req.SkillDirectories = config.SkillDirectories - req.DisabledSkills = config.DisabledSkills - req.InfiniteSessions = config.InfiniteSessions - } + req.ClientName = config.ClientName + req.Model = config.Model + req.ReasoningEffort = config.ReasoningEffort + req.SystemMessage = config.SystemMessage + req.Tools = config.Tools + req.Provider = config.Provider + req.AvailableTools = config.AvailableTools + req.ExcludedTools = config.ExcludedTools + if config.Streaming { + req.Streaming = Bool(true) + } + if config.OnUserInputRequest != nil { + req.RequestUserInput = Bool(true) + } + if config.Hooks != nil && (config.Hooks.OnPreToolUse != nil || + config.Hooks.OnPostToolUse != nil || + config.Hooks.OnUserPromptSubmitted != nil || + config.Hooks.OnSessionStart != nil || + config.Hooks.OnSessionEnd != nil || + config.Hooks.OnErrorOccurred != nil) { + req.Hooks = Bool(true) + } + req.WorkingDirectory = config.WorkingDirectory + req.ConfigDir = config.ConfigDir + if config.DisableResume { + req.DisableResume = Bool(true) + } + req.MCPServers = config.MCPServers + req.EnvValueMode = "direct" + req.CustomAgents = config.CustomAgents + req.SkillDirectories = config.SkillDirectories + req.DisabledSkills = config.DisabledSkills + req.InfiniteSessions = config.InfiniteSessions req.RequestPermission = Bool(true) result, err := c.client.Request("session.resume", req) @@ -598,19 +632,13 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, } session := newSession(response.SessionID, c.client, response.WorkspacePath) - if config != nil { - session.registerTools(config.Tools) - if config.OnPermissionRequest != nil { - session.registerPermissionHandler(config.OnPermissionRequest) - } - if config.OnUserInputRequest != nil { - session.registerUserInputHandler(config.OnUserInputRequest) - } - if config.Hooks != nil { - session.registerHooks(config.Hooks) - } - } else { - session.registerTools(nil) + session.registerTools(config.Tools) + session.registerPermissionHandler(config.OnPermissionRequest) + if config.OnUserInputRequest != nil { + session.registerUserInputHandler(config.OnUserInputRequest) + } + if config.Hooks != nil { + session.registerHooks(config.Hooks) } c.sessionsMux.Lock() @@ -662,8 +690,11 @@ func (c *Client) ListSessions(ctx context.Context, filter *SessionListFilter) ([ return response.Sessions, nil } -// DeleteSession permanently deletes a session and all its conversation history. +// DeleteSession permanently deletes a session and all its data from disk, +// including conversation history, planning state, and artifacts. // +// Unlike [Session.Disconnect], which only releases in-memory resources and +// preserves session data for later resumption, DeleteSession is irreversible. // The session cannot be resumed after deletion. If the session is in the local // sessions map, it will be removed. // @@ -703,6 +734,40 @@ func (c *Client) DeleteSession(ctx context.Context, sessionID string) error { return nil } +// GetLastSessionID returns the ID of the most recently updated session. +// +// This is useful for resuming the last conversation when the session ID +// was not stored. Returns nil if no sessions exist. +// +// Example: +// +// lastID, err := client.GetLastSessionID(context.Background()) +// if err != nil { +// log.Fatal(err) +// } +// if lastID != nil { +// session, err := client.ResumeSession(context.Background(), *lastID, &copilot.ResumeSessionConfig{ +// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, +// }) +// } +func (c *Client) GetLastSessionID(ctx context.Context) (*string, error) { + if err := c.ensureConnected(); err != nil { + return nil, err + } + + result, err := c.client.Request("session.getLastId", getLastSessionIDRequest{}) + if err != nil { + return nil, err + } + + var response getLastSessionIDResponse + if err := json.Unmarshal(result, &response); err != nil { + return nil, fmt.Errorf("failed to unmarshal getLastId response: %w", err) + } + + return response.SessionID, nil +} + // GetForegroundSessionID returns the ID of the session currently displayed in the TUI. // // This is only available when connecting to a server running in TUI+server mode @@ -881,12 +946,22 @@ func (c *Client) handleLifecycleEvent(event SessionLifecycleEvent) { // Example: // // if client.State() == copilot.StateConnected { -// session, err := client.CreateSession(context.Background(), nil) +// session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ +// OnPermissionRequest: copilot.PermissionHandler.ApproveAll, +// }) // } func (c *Client) State() ConnectionState { + c.startStopMux.RLock() + defer c.startStopMux.RUnlock() return c.state } +// ActualPort returns the TCP port the CLI server is listening on. +// Returns 0 if the client is not connected or using stdio transport. +func (c *Client) ActualPort() int { + return c.actualPort +} + // Ping sends a ping request to the server to verify connectivity. // // The message parameter is optional and will be echoed back in the response. @@ -994,22 +1069,28 @@ func (c *Client) ListModels(ctx context.Context) ([]ModelInfo, error) { return models, nil } -// verifyProtocolVersion verifies that the server's protocol version matches the SDK's expected version +// minProtocolVersion is the minimum protocol version this SDK can communicate with. +const minProtocolVersion = 2 + +// verifyProtocolVersion verifies that the server's protocol version is within the supported range +// and stores the negotiated version. func (c *Client) verifyProtocolVersion(ctx context.Context) error { - expectedVersion := GetSdkProtocolVersion() + maxVersion := GetSdkProtocolVersion() pingResult, err := c.Ping(ctx, "") if err != nil { return err } if pingResult.ProtocolVersion == nil { - return fmt.Errorf("SDK protocol version mismatch: SDK expects version %d, but server does not report a protocol version. Please update your server to ensure compatibility", expectedVersion) + return fmt.Errorf("SDK protocol version mismatch: SDK supports versions %d-%d, but server does not report a protocol version. Please update your server to ensure compatibility", minProtocolVersion, maxVersion) } - if *pingResult.ProtocolVersion != expectedVersion { - return fmt.Errorf("SDK protocol version mismatch: SDK expects version %d, but server reports version %d. Please update your SDK or server to ensure compatibility", expectedVersion, *pingResult.ProtocolVersion) + serverVersion := *pingResult.ProtocolVersion + if serverVersion < minProtocolVersion || serverVersion > maxVersion { + return fmt.Errorf("SDK protocol version mismatch: SDK supports versions %d-%d, but server reports version %d. Please update your SDK or server to ensure compatibility", minProtocolVersion, maxVersion, serverVersion) } + c.negotiatedProtocolVersion = serverVersion return nil } @@ -1040,14 +1121,14 @@ func (c *Client) startCLIServer(ctx context.Context) error { } // Add auth-related flags - if c.options.GithubToken != "" { + if c.options.GitHubToken != "" { args = append(args, "--auth-token-env", "COPILOT_SDK_AUTH_TOKEN") } - // Default useLoggedInUser to false when GithubToken is provided + // Default useLoggedInUser to false when GitHubToken is provided useLoggedInUser := true if c.options.UseLoggedInUser != nil { useLoggedInUser = *c.options.UseLoggedInUser - } else if c.options.GithubToken != "" { + } else if c.options.GitHubToken != "" { useLoggedInUser = false } if !useLoggedInUser { @@ -1074,8 +1155,8 @@ func (c *Client) startCLIServer(ctx context.Context) error { // Add auth token if needed. c.process.Env = c.options.Env - if c.options.GithubToken != "" { - c.process.Env = append(c.process.Env, "COPILOT_SDK_AUTH_TOKEN="+c.options.GithubToken) + if c.options.GitHubToken != "" { + c.process.Env = append(c.process.Env, "COPILOT_SDK_AUTH_TOKEN="+c.options.GitHubToken) } if c.useStdio { @@ -1094,21 +1175,11 @@ func (c *Client) startCLIServer(ctx context.Context) error { return fmt.Errorf("failed to start CLI server: %w", err) } - // Monitor process exit to signal pending requests - c.processDone = make(chan struct{}) - go func() { - waitErr := c.process.Wait() - if waitErr != nil { - c.processError = fmt.Errorf("CLI process exited: %v", waitErr) - } else { - c.processError = fmt.Errorf("CLI process exited unexpectedly") - } - close(c.processDone) - }() + c.monitorProcess() // Create JSON-RPC client immediately c.client = jsonrpc2.NewClient(stdin, stdout) - c.client.SetProcessDone(c.processDone, &c.processError) + c.client.SetProcessDone(c.processDone, c.processErrorPtr) c.RPC = rpc.NewServerRpc(c.client) c.setupNotificationHandler() c.client.Start() @@ -1125,7 +1196,8 @@ func (c *Client) startCLIServer(ctx context.Context) error { return fmt.Errorf("failed to start CLI server: %w", err) } - // Wait for port announcement + c.monitorProcess() + scanner := bufio.NewScanner(stdout) timeout := time.After(10 * time.Second) portRegex := regexp.MustCompile(`listening on port (\d+)`) @@ -1133,14 +1205,19 @@ func (c *Client) startCLIServer(ctx context.Context) error { for { select { case <-timeout: - return fmt.Errorf("timeout waiting for CLI server to start") + killErr := c.killProcess() + return errors.Join(errors.New("timeout waiting for CLI server to start"), killErr) + case <-c.processDone: + killErr := c.killProcess() + return errors.Join(errors.New("CLI server process exited before reporting port"), killErr) default: if scanner.Scan() { line := scanner.Text() if matches := portRegex.FindStringSubmatch(line); len(matches) > 1 { port, err := strconv.Atoi(matches[1]) if err != nil { - return fmt.Errorf("failed to parse port: %w", err) + killErr := c.killProcess() + return errors.Join(fmt.Errorf("failed to parse port: %w", err), killErr) } c.actualPort = port return nil @@ -1151,6 +1228,39 @@ func (c *Client) startCLIServer(ctx context.Context) error { } } +func (c *Client) killProcess() error { + if p := c.osProcess.Swap(nil); p != nil { + if err := p.Kill(); err != nil { + return fmt.Errorf("failed to kill CLI process: %w", err) + } + } + c.process = nil + return nil +} + +// monitorProcess signals when the CLI process exits and captures any exit error. +// processError is intentionally a local: each process lifecycle gets its own +// error value, so goroutines from previous processes can't overwrite the +// current one. Closing the channel synchronizes with readers, guaranteeing +// they see the final processError value. +func (c *Client) monitorProcess() { + done := make(chan struct{}) + c.processDone = done + proc := c.process + c.osProcess.Store(proc.Process) + var processError error + c.processErrorPtr = &processError + go func() { + waitErr := proc.Wait() + if waitErr != nil { + processError = fmt.Errorf("CLI process exited: %w", waitErr) + } else { + processError = errors.New("CLI process exited unexpectedly") + } + close(done) + }() +} + // connectToServer establishes a connection to the server. func (c *Client) connectToServer(ctx context.Context) error { if c.useStdio { @@ -1182,6 +1292,9 @@ func (c *Client) connectViaTcp(ctx context.Context) error { // Create JSON-RPC client with the connection c.client = jsonrpc2.NewClient(conn, conn) + if c.processDone != nil { + c.client.SetProcessDone(c.processDone, c.processErrorPtr) + } c.RPC = rpc.NewServerRpc(c.client) c.setupNotificationHandler() c.client.Start() @@ -1189,12 +1302,16 @@ func (c *Client) connectViaTcp(ctx context.Context) error { return nil } -// setupNotificationHandler configures handlers for session events, tool calls, and permission requests. +// setupNotificationHandler configures handlers for session events and RPC requests. +// Protocol v3 servers send tool calls and permission requests as broadcast session events. +// Protocol v2 servers use the older tool.call / permission.request RPC model. +// We always register v2 adapters because handlers are set up before version negotiation; +// a v3 server will simply never send these requests. func (c *Client) setupNotificationHandler() { c.client.SetRequestHandler("session.event", jsonrpc2.NotificationHandlerFor(c.handleSessionEvent)) c.client.SetRequestHandler("session.lifecycle", jsonrpc2.NotificationHandlerFor(c.handleLifecycleEvent)) - c.client.SetRequestHandler("tool.call", jsonrpc2.RequestHandlerFor(c.handleToolCallRequest)) - c.client.SetRequestHandler("permission.request", jsonrpc2.RequestHandlerFor(c.handlePermissionRequest)) + c.client.SetRequestHandler("tool.call", jsonrpc2.RequestHandlerFor(c.handleToolCallRequestV2)) + c.client.SetRequestHandler("permission.request", jsonrpc2.RequestHandlerFor(c.handlePermissionRequestV2)) c.client.SetRequestHandler("userInput.request", jsonrpc2.RequestHandlerFor(c.handleUserInputRequest)) c.client.SetRequestHandler("hooks.invoke", jsonrpc2.RequestHandlerFor(c.handleHooksInvoke)) } @@ -1213,10 +1330,10 @@ func (c *Client) handleSessionEvent(req sessionEventRequest) { } } -// handleToolCallRequest handles a tool call request from the CLI server. -func (c *Client) handleToolCallRequest(req toolCallRequest) (*toolCallResponse, *jsonrpc2.Error) { - if req.SessionID == "" || req.ToolCallID == "" || req.ToolName == "" { - return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid tool call payload"} +// handleUserInputRequest handles a user input request from the CLI server. +func (c *Client) handleUserInputRequest(req userInputRequest) (*userInputResponse, *jsonrpc2.Error) { + if req.SessionID == "" || req.Question == "" { + return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid user input request payload"} } c.sessionsMux.Lock() @@ -1226,49 +1343,22 @@ func (c *Client) handleToolCallRequest(req toolCallRequest) (*toolCallResponse, return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} } - handler, ok := session.getToolHandler(req.ToolName) - if !ok { - return &toolCallResponse{Result: buildUnsupportedToolResult(req.ToolName)}, nil - } - - result := c.executeToolCall(req.SessionID, req.ToolCallID, req.ToolName, req.Arguments, handler) - return &toolCallResponse{Result: result}, nil -} - -// executeToolCall executes a tool handler and returns the result. -func (c *Client) executeToolCall( - sessionID, toolCallID, toolName string, - arguments any, - handler ToolHandler, -) (result ToolResult) { - invocation := ToolInvocation{ - SessionID: sessionID, - ToolCallID: toolCallID, - ToolName: toolName, - Arguments: arguments, - } - - defer func() { - if r := recover(); r != nil { - result = buildFailedToolResult(fmt.Sprintf("tool panic: %v", r)) - } - }() - - if handler != nil { - var err error - result, err = handler(invocation) - if err != nil { - result = buildFailedToolResult(err.Error()) - } + response, err := session.handleUserInputRequest(UserInputRequest{ + Question: req.Question, + Choices: req.Choices, + AllowFreeform: req.AllowFreeform, + }) + if err != nil { + return nil, &jsonrpc2.Error{Code: -32603, Message: err.Error()} } - return result + return &userInputResponse{Answer: response.Answer, WasFreeform: response.WasFreeform}, nil } -// handlePermissionRequest handles a permission request from the CLI server. -func (c *Client) handlePermissionRequest(req permissionRequestRequest) (*permissionRequestResponse, *jsonrpc2.Error) { - if req.SessionID == "" { - return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid permission request payload"} +// handleHooksInvoke handles a hooks invocation from the CLI server. +func (c *Client) handleHooksInvoke(req hooksInvokeRequest) (map[string]any, *jsonrpc2.Error) { + if req.SessionID == "" || req.Type == "" { + return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid hooks invoke payload"} } c.sessionsMux.Lock() @@ -1278,23 +1368,50 @@ func (c *Client) handlePermissionRequest(req permissionRequestRequest) (*permiss return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} } - result, err := session.handlePermissionRequest(req.Request) + output, err := session.handleHooksInvoke(req.Type, req.Input) if err != nil { - // Return denial on error - return &permissionRequestResponse{ - Result: PermissionRequestResult{ - Kind: "denied-no-approval-rule-and-could-not-request-from-user", - }, - }, nil + return nil, &jsonrpc2.Error{Code: -32603, Message: err.Error()} + } + + result := make(map[string]any) + if output != nil { + result["output"] = output } + return result, nil +} + +// ======================================================================== +// Protocol v2 backward-compatibility adapters +// ======================================================================== - return &permissionRequestResponse{Result: result}, nil +// toolCallRequestV2 is the v2 RPC request payload for tool.call. +type toolCallRequestV2 struct { + SessionID string `json:"sessionId"` + ToolCallID string `json:"toolCallId"` + ToolName string `json:"toolName"` + Arguments any `json:"arguments"` } -// handleUserInputRequest handles a user input request from the CLI server. -func (c *Client) handleUserInputRequest(req userInputRequest) (*userInputResponse, *jsonrpc2.Error) { - if req.SessionID == "" || req.Question == "" { - return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid user input request payload"} +// toolCallResponseV2 is the v2 RPC response payload for tool.call. +type toolCallResponseV2 struct { + Result ToolResult `json:"result"` +} + +// permissionRequestV2 is the v2 RPC request payload for permission.request. +type permissionRequestV2 struct { + SessionID string `json:"sessionId"` + Request PermissionRequest `json:"permissionRequest"` +} + +// permissionResponseV2 is the v2 RPC response payload for permission.request. +type permissionResponseV2 struct { + Result PermissionRequestResult `json:"result"` +} + +// handleToolCallRequestV2 handles a v2-style tool.call RPC request from the server. +func (c *Client) handleToolCallRequestV2(req toolCallRequestV2) (*toolCallResponseV2, *jsonrpc2.Error) { + if req.SessionID == "" || req.ToolCallID == "" || req.ToolName == "" { + return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid tool call payload"} } c.sessionsMux.Lock() @@ -1304,22 +1421,35 @@ func (c *Client) handleUserInputRequest(req userInputRequest) (*userInputRespons return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} } - response, err := session.handleUserInputRequest(UserInputRequest{ - Question: req.Question, - Choices: req.Choices, - AllowFreeform: req.AllowFreeform, - }) + handler, ok := session.getToolHandler(req.ToolName) + if !ok { + return &toolCallResponseV2{Result: ToolResult{ + TextResultForLLM: fmt.Sprintf("Tool '%s' is not supported by this client instance.", req.ToolName), + ResultType: "failure", + Error: fmt.Sprintf("tool '%s' not supported", req.ToolName), + ToolTelemetry: map[string]any{}, + }}, nil + } + + invocation := ToolInvocation(req) + + result, err := handler(invocation) if err != nil { - return nil, &jsonrpc2.Error{Code: -32603, Message: err.Error()} + return &toolCallResponseV2{Result: ToolResult{ + TextResultForLLM: "Invoking this tool produced an error. Detailed information is not available.", + ResultType: "failure", + Error: err.Error(), + ToolTelemetry: map[string]any{}, + }}, nil } - return &userInputResponse{Answer: response.Answer, WasFreeform: response.WasFreeform}, nil + return &toolCallResponseV2{Result: result}, nil } -// handleHooksInvoke handles a hooks invocation from the CLI server. -func (c *Client) handleHooksInvoke(req hooksInvokeRequest) (map[string]any, *jsonrpc2.Error) { - if req.SessionID == "" || req.Type == "" { - return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid hooks invoke payload"} +// handlePermissionRequestV2 handles a v2-style permission.request RPC request from the server. +func (c *Client) handlePermissionRequestV2(req permissionRequestV2) (*permissionResponseV2, *jsonrpc2.Error) { + if req.SessionID == "" { + return nil, &jsonrpc2.Error{Code: -32602, Message: "invalid permission request payload"} } c.sessionsMux.Lock() @@ -1329,34 +1459,27 @@ func (c *Client) handleHooksInvoke(req hooksInvokeRequest) (map[string]any, *jso return nil, &jsonrpc2.Error{Code: -32602, Message: fmt.Sprintf("unknown session %s", req.SessionID)} } - output, err := session.handleHooksInvoke(req.Type, req.Input) - if err != nil { - return nil, &jsonrpc2.Error{Code: -32603, Message: err.Error()} + handler := session.getPermissionHandler() + if handler == nil { + return &permissionResponseV2{ + Result: PermissionRequestResult{ + Kind: PermissionRequestResultKindDeniedCouldNotRequestFromUser, + }, + }, nil } - result := make(map[string]any) - if output != nil { - result["output"] = output + invocation := PermissionInvocation{ + SessionID: session.SessionID, } - return result, nil -} -// The detailed error is stored in the Error field but not exposed to the LLM for security. -func buildFailedToolResult(internalError string) ToolResult { - return ToolResult{ - TextResultForLLM: "Invoking this tool produced an error. Detailed information is not available.", - ResultType: "failure", - Error: internalError, - ToolTelemetry: map[string]any{}, + result, err := handler(req.Request, invocation) + if err != nil { + return &permissionResponseV2{ + Result: PermissionRequestResult{ + Kind: PermissionRequestResultKindDeniedCouldNotRequestFromUser, + }, + }, nil } -} -// buildUnsupportedToolResult creates a failure ToolResult for an unsupported tool. -func buildUnsupportedToolResult(toolName string) ToolResult { - return ToolResult{ - TextResultForLLM: fmt.Sprintf("Tool '%s' is not supported by this client instance.", toolName), - ResultType: "failure", - Error: fmt.Sprintf("tool '%s' not supported", toolName), - ToolTelemetry: map[string]any{}, - } + return &permissionResponseV2{Result: result}, nil } diff --git a/go/client_test.go b/go/client_test.go index b2e9cdce6..d740fd79b 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -6,44 +6,12 @@ import ( "path/filepath" "reflect" "regexp" + "sync" "testing" ) // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.go instead -func TestClient_HandleToolCallRequest(t *testing.T) { - t.Run("returns a standardized failure result when a tool is not registered", func(t *testing.T) { - cliPath := findCLIPathForTest() - if cliPath == "" { - t.Skip("CLI not found") - } - - client := NewClient(&ClientOptions{CLIPath: cliPath}) - t.Cleanup(func() { client.ForceStop() }) - - session, err := client.CreateSession(t.Context(), nil) - if err != nil { - t.Fatalf("Failed to create session: %v", err) - } - - params := toolCallRequest{ - SessionID: session.SessionID, - ToolCallID: "123", - ToolName: "missing_tool", - Arguments: map[string]any{}, - } - response, _ := client.handleToolCallRequest(params) - - if response.Result.ResultType != "failure" { - t.Errorf("Expected resultType to be 'failure', got %q", response.Result.ResultType) - } - - if response.Result.Error != "tool 'missing_tool' not supported" { - t.Errorf("Expected error to be \"tool 'missing_tool' not supported\", got %q", response.Result.Error) - } - }) -} - func TestClient_URLParsing(t *testing.T) { t.Run("should parse port-only URL format", func(t *testing.T) { client := NewClient(&ClientOptions{ @@ -255,17 +223,17 @@ func TestClient_URLParsing(t *testing.T) { } func TestClient_AuthOptions(t *testing.T) { - t.Run("should accept GithubToken option", func(t *testing.T) { + t.Run("should accept GitHubToken option", func(t *testing.T) { client := NewClient(&ClientOptions{ - GithubToken: "gho_test_token", + GitHubToken: "gho_test_token", }) - if client.options.GithubToken != "gho_test_token" { - t.Errorf("Expected GithubToken to be 'gho_test_token', got %q", client.options.GithubToken) + if client.options.GitHubToken != "gho_test_token" { + t.Errorf("Expected GitHubToken to be 'gho_test_token', got %q", client.options.GitHubToken) } }) - t.Run("should default UseLoggedInUser to nil when no GithubToken", func(t *testing.T) { + t.Run("should default UseLoggedInUser to nil when no GitHubToken", func(t *testing.T) { client := NewClient(&ClientOptions{}) if client.options.UseLoggedInUser != nil { @@ -283,9 +251,9 @@ func TestClient_AuthOptions(t *testing.T) { } }) - t.Run("should allow explicit UseLoggedInUser true with GithubToken", func(t *testing.T) { + t.Run("should allow explicit UseLoggedInUser true with GitHubToken", func(t *testing.T) { client := NewClient(&ClientOptions{ - GithubToken: "gho_test_token", + GitHubToken: "gho_test_token", UseLoggedInUser: Bool(true), }) @@ -294,12 +262,12 @@ func TestClient_AuthOptions(t *testing.T) { } }) - t.Run("should throw error when GithubToken is used with CLIUrl", func(t *testing.T) { + t.Run("should throw error when GitHubToken is used with CLIUrl", func(t *testing.T) { defer func() { if r := recover(); r == nil { t.Error("Expected panic for auth options with CLIUrl") } else { - matched, _ := regexp.MatchString("GithubToken and UseLoggedInUser cannot be used with CLIUrl", r.(string)) + matched, _ := regexp.MatchString("GitHubToken and UseLoggedInUser cannot be used with CLIUrl", r.(string)) if !matched { t.Errorf("Expected panic message about auth options, got: %v", r) } @@ -308,7 +276,7 @@ func TestClient_AuthOptions(t *testing.T) { NewClient(&ClientOptions{ CLIUrl: "localhost:8080", - GithubToken: "gho_test_token", + GitHubToken: "gho_test_token", }) }) @@ -317,7 +285,7 @@ func TestClient_AuthOptions(t *testing.T) { if r := recover(); r == nil { t.Error("Expected panic for auth options with CLIUrl") } else { - matched, _ := regexp.MatchString("GithubToken and UseLoggedInUser cannot be used with CLIUrl", r.(string)) + matched, _ := regexp.MatchString("GitHubToken and UseLoggedInUser cannot be used with CLIUrl", r.(string)) if !matched { t.Errorf("Expected panic message about auth options, got: %v", r) } @@ -444,3 +412,125 @@ func TestResumeSessionRequest_ClientName(t *testing.T) { } }) } + +func TestOverridesBuiltInTool(t *testing.T) { + t.Run("OverridesBuiltInTool is serialized in tool definition", func(t *testing.T) { + tool := Tool{ + Name: "grep", + Description: "Custom grep", + OverridesBuiltInTool: true, + Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil }, + } + data, err := json.Marshal(tool) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if v, ok := m["overridesBuiltInTool"]; !ok || v != true { + t.Errorf("expected overridesBuiltInTool=true, got %v", m) + } + }) + + t.Run("OverridesBuiltInTool omitted when false", func(t *testing.T) { + tool := Tool{ + Name: "custom_tool", + Description: "A custom tool", + Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil }, + } + data, err := json.Marshal(tool) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if _, ok := m["overridesBuiltInTool"]; ok { + t.Errorf("expected overridesBuiltInTool to be omitted, got %v", m) + } + }) +} + +func TestClient_CreateSession_RequiresPermissionHandler(t *testing.T) { + t.Run("returns error when config is nil", func(t *testing.T) { + client := NewClient(nil) + _, err := client.CreateSession(t.Context(), nil) + if err == nil { + t.Fatal("Expected error when OnPermissionRequest is nil") + } + matched, _ := regexp.MatchString("OnPermissionRequest.*is required", err.Error()) + if !matched { + t.Errorf("Expected error about OnPermissionRequest being required, got: %v", err) + } + }) + + t.Run("returns error when OnPermissionRequest is not set", func(t *testing.T) { + client := NewClient(nil) + _, err := client.CreateSession(t.Context(), &SessionConfig{}) + if err == nil { + t.Fatal("Expected error when OnPermissionRequest is nil") + } + matched, _ := regexp.MatchString("OnPermissionRequest.*is required", err.Error()) + if !matched { + t.Errorf("Expected error about OnPermissionRequest being required, got: %v", err) + } + }) +} + +func TestClient_ResumeSession_RequiresPermissionHandler(t *testing.T) { + t.Run("returns error when config is nil", func(t *testing.T) { + client := NewClient(nil) + _, err := client.ResumeSessionWithOptions(t.Context(), "some-id", nil) + if err == nil { + t.Fatal("Expected error when OnPermissionRequest is nil") + } + matched, _ := regexp.MatchString("OnPermissionRequest.*is required", err.Error()) + if !matched { + t.Errorf("Expected error about OnPermissionRequest being required, got: %v", err) + } + }) +} + +func TestClient_StartStopRace(t *testing.T) { + cliPath := findCLIPathForTest() + if cliPath == "" { + t.Skip("CLI not found") + } + client := NewClient(&ClientOptions{CLIPath: cliPath}) + defer client.ForceStop() + errChan := make(chan error) + wg := sync.WaitGroup{} + for range 10 { + wg.Add(3) + go func() { + defer wg.Done() + if err := client.Start(t.Context()); err != nil { + select { + case errChan <- err: + default: + } + } + }() + go func() { + defer wg.Done() + if err := client.Stop(); err != nil { + select { + case errChan <- err: + default: + } + } + }() + go func() { + defer wg.Done() + client.ForceStop() + }() + } + wg.Wait() + close(errChan) + if err := <-errChan; err != nil { + t.Fatal(err) + } +} diff --git a/go/generated_session_events.go b/go/generated_session_events.go index c11a43c5a..86f5066f7 100644 --- a/go/generated_session_events.go +++ b/go/generated_session_events.go @@ -26,285 +26,849 @@ func (r *SessionEvent) Marshal() ([]byte, error) { } type SessionEvent struct { - Data Data `json:"data"` - Ephemeral *bool `json:"ephemeral,omitempty"` - ID string `json:"id"` - ParentID *string `json:"parentId"` + // Payload indicating the agent is idle; includes any background tasks still in flight + // + // Empty payload; the event signals that LLM-powered conversation compaction has begun + // + // Empty payload; the event signals that the pending message queue has changed + // + // Empty payload; the event signals that the custom agent was deselected, returning to the + // default agent + Data Data `json:"data"` + // When true, the event is transient and not persisted to the session event log on disk + Ephemeral *bool `json:"ephemeral,omitempty"` + // Unique event identifier (UUID v4), generated when the event is emitted + ID string `json:"id"` + // ID of the chronologically preceding event in the session, forming a linked chain. Null + // for the first event. + ParentID *string `json:"parentId"` + // ISO 8601 timestamp when the event was created Timestamp time.Time `json:"timestamp"` Type SessionEventType `json:"type"` } +// Payload indicating the agent is idle; includes any background tasks still in flight +// +// Empty payload; the event signals that LLM-powered conversation compaction has begun +// +// Empty payload; the event signals that the pending message queue has changed +// +// Empty payload; the event signals that the custom agent was deselected, returning to the +// default agent type Data struct { - Context *ContextUnion `json:"context"` - CopilotVersion *string `json:"copilotVersion,omitempty"` - Producer *string `json:"producer,omitempty"` - SelectedModel *string `json:"selectedModel,omitempty"` - SessionID *string `json:"sessionId,omitempty"` - StartTime *time.Time `json:"startTime,omitempty"` - Version *float64 `json:"version,omitempty"` - EventCount *float64 `json:"eventCount,omitempty"` - ResumeTime *time.Time `json:"resumeTime,omitempty"` - ErrorType *string `json:"errorType,omitempty"` - Message *string `json:"message,omitempty"` - ProviderCallID *string `json:"providerCallId,omitempty"` - Stack *string `json:"stack,omitempty"` - StatusCode *int64 `json:"statusCode,omitempty"` - Title *string `json:"title,omitempty"` - InfoType *string `json:"infoType,omitempty"` - WarningType *string `json:"warningType,omitempty"` - NewModel *string `json:"newModel,omitempty"` - PreviousModel *string `json:"previousModel,omitempty"` - NewMode *string `json:"newMode,omitempty"` - PreviousMode *string `json:"previousMode,omitempty"` - Operation *Operation `json:"operation,omitempty"` - // Relative path within the workspace files directory - Path *string `json:"path,omitempty"` - HandoffTime *time.Time `json:"handoffTime,omitempty"` - RemoteSessionID *string `json:"remoteSessionId,omitempty"` - Repository *RepositoryUnion `json:"repository"` - SourceType *SourceType `json:"sourceType,omitempty"` - Summary *string `json:"summary,omitempty"` - MessagesRemovedDuringTruncation *float64 `json:"messagesRemovedDuringTruncation,omitempty"` - PerformedBy *string `json:"performedBy,omitempty"` - PostTruncationMessagesLength *float64 `json:"postTruncationMessagesLength,omitempty"` - PostTruncationTokensInMessages *float64 `json:"postTruncationTokensInMessages,omitempty"` - PreTruncationMessagesLength *float64 `json:"preTruncationMessagesLength,omitempty"` - PreTruncationTokensInMessages *float64 `json:"preTruncationTokensInMessages,omitempty"` - TokenLimit *float64 `json:"tokenLimit,omitempty"` - TokensRemovedDuringTruncation *float64 `json:"tokensRemovedDuringTruncation,omitempty"` - EventsRemoved *float64 `json:"eventsRemoved,omitempty"` - UpToEventID *string `json:"upToEventId,omitempty"` - CodeChanges *CodeChanges `json:"codeChanges,omitempty"` - CurrentModel *string `json:"currentModel,omitempty"` - ErrorReason *string `json:"errorReason,omitempty"` - ModelMetrics map[string]ModelMetric `json:"modelMetrics,omitempty"` - SessionStartTime *float64 `json:"sessionStartTime,omitempty"` - ShutdownType *ShutdownType `json:"shutdownType,omitempty"` - TotalAPIDurationMS *float64 `json:"totalApiDurationMs,omitempty"` - TotalPremiumRequests *float64 `json:"totalPremiumRequests,omitempty"` - Branch *string `json:"branch,omitempty"` - Cwd *string `json:"cwd,omitempty"` - GitRoot *string `json:"gitRoot,omitempty"` - CurrentTokens *float64 `json:"currentTokens,omitempty"` - MessagesLength *float64 `json:"messagesLength,omitempty"` - CheckpointNumber *float64 `json:"checkpointNumber,omitempty"` - CheckpointPath *string `json:"checkpointPath,omitempty"` - CompactionTokensUsed *CompactionTokensUsed `json:"compactionTokensUsed,omitempty"` - Error *ErrorUnion `json:"error"` - MessagesRemoved *float64 `json:"messagesRemoved,omitempty"` - PostCompactionTokens *float64 `json:"postCompactionTokens,omitempty"` - PreCompactionMessagesLength *float64 `json:"preCompactionMessagesLength,omitempty"` - PreCompactionTokens *float64 `json:"preCompactionTokens,omitempty"` - RequestID *string `json:"requestId,omitempty"` - Success *bool `json:"success,omitempty"` - SummaryContent *string `json:"summaryContent,omitempty"` - TokensRemoved *float64 `json:"tokensRemoved,omitempty"` - AgentMode *AgentMode `json:"agentMode,omitempty"` - Attachments []Attachment `json:"attachments,omitempty"` - Content *string `json:"content,omitempty"` - Source *string `json:"source,omitempty"` - TransformedContent *string `json:"transformedContent,omitempty"` - TurnID *string `json:"turnId,omitempty"` - Intent *string `json:"intent,omitempty"` - ReasoningID *string `json:"reasoningId,omitempty"` - DeltaContent *string `json:"deltaContent,omitempty"` - EncryptedContent *string `json:"encryptedContent,omitempty"` - MessageID *string `json:"messageId,omitempty"` - ParentToolCallID *string `json:"parentToolCallId,omitempty"` - Phase *string `json:"phase,omitempty"` - ReasoningOpaque *string `json:"reasoningOpaque,omitempty"` - ReasoningText *string `json:"reasoningText,omitempty"` - ToolRequests []ToolRequest `json:"toolRequests,omitempty"` - TotalResponseSizeBytes *float64 `json:"totalResponseSizeBytes,omitempty"` - APICallID *string `json:"apiCallId,omitempty"` - CacheReadTokens *float64 `json:"cacheReadTokens,omitempty"` - CacheWriteTokens *float64 `json:"cacheWriteTokens,omitempty"` - Cost *float64 `json:"cost,omitempty"` - Duration *float64 `json:"duration,omitempty"` - Initiator *string `json:"initiator,omitempty"` - InputTokens *float64 `json:"inputTokens,omitempty"` - Model *string `json:"model,omitempty"` - OutputTokens *float64 `json:"outputTokens,omitempty"` - QuotaSnapshots map[string]QuotaSnapshot `json:"quotaSnapshots,omitempty"` - Reason *string `json:"reason,omitempty"` - Arguments interface{} `json:"arguments"` - ToolCallID *string `json:"toolCallId,omitempty"` - ToolName *string `json:"toolName,omitempty"` - MCPServerName *string `json:"mcpServerName,omitempty"` - MCPToolName *string `json:"mcpToolName,omitempty"` - PartialOutput *string `json:"partialOutput,omitempty"` - ProgressMessage *string `json:"progressMessage,omitempty"` - IsUserRequested *bool `json:"isUserRequested,omitempty"` - Result *Result `json:"result,omitempty"` - ToolTelemetry map[string]interface{} `json:"toolTelemetry,omitempty"` - AllowedTools []string `json:"allowedTools,omitempty"` - Name *string `json:"name,omitempty"` - AgentDescription *string `json:"agentDescription,omitempty"` - AgentDisplayName *string `json:"agentDisplayName,omitempty"` - AgentName *string `json:"agentName,omitempty"` - Tools []string `json:"tools"` - HookInvocationID *string `json:"hookInvocationId,omitempty"` - HookType *string `json:"hookType,omitempty"` - Input interface{} `json:"input"` - Output interface{} `json:"output"` - Metadata *Metadata `json:"metadata,omitempty"` - Role *Role `json:"role,omitempty"` + // Working directory and git context at session start + // + // Updated working directory and git context at resume time + // + // Additional context information for the handoff + Context *ContextUnion `json:"context"` + // Version string of the Copilot application + CopilotVersion *string `json:"copilotVersion,omitempty"` + // Identifier of the software producing the events (e.g., "copilot-agent") + Producer *string `json:"producer,omitempty"` + // Model selected at session creation time, if any + SelectedModel *string `json:"selectedModel,omitempty"` + // Unique identifier for the session + // + // Session ID that this external tool request belongs to + SessionID *string `json:"sessionId,omitempty"` + // ISO 8601 timestamp when the session was created + StartTime *time.Time `json:"startTime,omitempty"` + // Schema version number for the session event format + Version *float64 `json:"version,omitempty"` + // Total number of persisted events in the session at the time of resume + EventCount *float64 `json:"eventCount,omitempty"` + // ISO 8601 timestamp when the session was resumed + ResumeTime *time.Time `json:"resumeTime,omitempty"` + // Category of error (e.g., "authentication", "authorization", "quota", "rate_limit", + // "query") + ErrorType *string `json:"errorType,omitempty"` + // Human-readable error message + // + // Human-readable informational message for display in the timeline + // + // Human-readable warning message for display in the timeline + // + // Message describing what information is needed from the user + Message *string `json:"message,omitempty"` + // GitHub request tracing ID (x-github-request-id header) for correlating with server-side + // logs + // + // GitHub request tracing ID (x-github-request-id header) for server-side log correlation + ProviderCallID *string `json:"providerCallId,omitempty"` + // Error stack trace, when available + Stack *string `json:"stack,omitempty"` + // HTTP status code from the upstream request, if applicable + StatusCode *int64 `json:"statusCode,omitempty"` + // Background tasks still running when the agent became idle + BackgroundTasks *BackgroundTasks `json:"backgroundTasks,omitempty"` + // The new display title for the session + Title *string `json:"title,omitempty"` + // Category of informational message (e.g., "notification", "timing", "context_window", + // "mcp", "snapshot", "configuration", "authentication", "model") + InfoType *string `json:"infoType,omitempty"` + // Category of warning (e.g., "subscription", "policy", "mcp") + WarningType *string `json:"warningType,omitempty"` + // Newly selected model identifier + NewModel *string `json:"newModel,omitempty"` + // Model that was previously selected, if any + PreviousModel *string `json:"previousModel,omitempty"` + // Agent mode after the change (e.g., "interactive", "plan", "autopilot") + NewMode *string `json:"newMode,omitempty"` + // Agent mode before the change (e.g., "interactive", "plan", "autopilot") + PreviousMode *string `json:"previousMode,omitempty"` + // The type of operation performed on the plan file + // + // Whether the file was newly created or updated + Operation *Operation `json:"operation,omitempty"` + // Relative path within the session workspace files directory + // + // File path to the SKILL.md definition + Path *string `json:"path,omitempty"` + // ISO 8601 timestamp when the handoff occurred + HandoffTime *time.Time `json:"handoffTime,omitempty"` + // Session ID of the remote session being handed off + RemoteSessionID *string `json:"remoteSessionId,omitempty"` + // Repository context for the handed-off session + // + // Repository identifier in "owner/name" format, derived from the git remote URL + Repository *RepositoryUnion `json:"repository"` + // Origin type of the session being handed off + SourceType *SourceType `json:"sourceType,omitempty"` + // Summary of the work done in the source session + // + // Optional summary of the completed task, provided by the agent + // + // Summary of the plan that was created + Summary *string `json:"summary,omitempty"` + // Number of messages removed by truncation + MessagesRemovedDuringTruncation *float64 `json:"messagesRemovedDuringTruncation,omitempty"` + // Identifier of the component that performed truncation (e.g., "BasicTruncator") + PerformedBy *string `json:"performedBy,omitempty"` + // Number of conversation messages after truncation + PostTruncationMessagesLength *float64 `json:"postTruncationMessagesLength,omitempty"` + // Total tokens in conversation messages after truncation + PostTruncationTokensInMessages *float64 `json:"postTruncationTokensInMessages,omitempty"` + // Number of conversation messages before truncation + PreTruncationMessagesLength *float64 `json:"preTruncationMessagesLength,omitempty"` + // Total tokens in conversation messages before truncation + PreTruncationTokensInMessages *float64 `json:"preTruncationTokensInMessages,omitempty"` + // Maximum token count for the model's context window + TokenLimit *float64 `json:"tokenLimit,omitempty"` + // Number of tokens removed by truncation + TokensRemovedDuringTruncation *float64 `json:"tokensRemovedDuringTruncation,omitempty"` + // Number of events that were removed by the rewind + EventsRemoved *float64 `json:"eventsRemoved,omitempty"` + // Event ID that was rewound to; all events after this one were removed + UpToEventID *string `json:"upToEventId,omitempty"` + // Aggregate code change metrics for the session + CodeChanges *CodeChanges `json:"codeChanges,omitempty"` + // Model that was selected at the time of shutdown + CurrentModel *string `json:"currentModel,omitempty"` + // Error description when shutdownType is "error" + ErrorReason *string `json:"errorReason,omitempty"` + // Per-model usage breakdown, keyed by model identifier + ModelMetrics map[string]ModelMetric `json:"modelMetrics,omitempty"` + // Unix timestamp (milliseconds) when the session started + SessionStartTime *float64 `json:"sessionStartTime,omitempty"` + // Whether the session ended normally ("routine") or due to a crash/fatal error ("error") + ShutdownType *ShutdownType `json:"shutdownType,omitempty"` + // Cumulative time spent in API calls during the session, in milliseconds + TotalAPIDurationMS *float64 `json:"totalApiDurationMs,omitempty"` + // Total number of premium API requests used during the session + TotalPremiumRequests *float64 `json:"totalPremiumRequests,omitempty"` + // Current git branch name + Branch *string `json:"branch,omitempty"` + // Current working directory path + Cwd *string `json:"cwd,omitempty"` + // Root directory of the git repository, resolved via git rev-parse + GitRoot *string `json:"gitRoot,omitempty"` + // Current number of tokens in the context window + CurrentTokens *float64 `json:"currentTokens,omitempty"` + // Current number of messages in the conversation + MessagesLength *float64 `json:"messagesLength,omitempty"` + // Checkpoint snapshot number created for recovery + CheckpointNumber *float64 `json:"checkpointNumber,omitempty"` + // File path where the checkpoint was stored + CheckpointPath *string `json:"checkpointPath,omitempty"` + // Token usage breakdown for the compaction LLM call + CompactionTokensUsed *CompactionTokensUsed `json:"compactionTokensUsed,omitempty"` + // Error message if compaction failed + // + // Error details when the tool execution failed + // + // Error message describing why the sub-agent failed + // + // Error details when the hook failed + Error *ErrorUnion `json:"error"` + // Number of messages removed during compaction + MessagesRemoved *float64 `json:"messagesRemoved,omitempty"` + // Total tokens in conversation after compaction + PostCompactionTokens *float64 `json:"postCompactionTokens,omitempty"` + // Number of messages before compaction + PreCompactionMessagesLength *float64 `json:"preCompactionMessagesLength,omitempty"` + // Total tokens in conversation before compaction + PreCompactionTokens *float64 `json:"preCompactionTokens,omitempty"` + // GitHub request tracing ID (x-github-request-id header) for the compaction LLM call + // + // Unique identifier for this permission request; used to respond via + // session.respondToPermission() + // + // Request ID of the resolved permission request; clients should dismiss any UI for this + // request + // + // Unique identifier for this input request; used to respond via + // session.respondToUserInput() + // + // Request ID of the resolved user input request; clients should dismiss any UI for this + // request + // + // Unique identifier for this elicitation request; used to respond via + // session.respondToElicitation() + // + // Request ID of the resolved elicitation request; clients should dismiss any UI for this + // request + // + // Unique identifier for this request; used to respond via session.respondToExternalTool() + // + // Request ID of the resolved external tool request; clients should dismiss any UI for this + // request + // + // Unique identifier for this request; used to respond via session.respondToQueuedCommand() + // + // Request ID of the resolved command request; clients should dismiss any UI for this + // request + // + // Unique identifier for this request; used to respond via session.respondToExitPlanMode() + // + // Request ID of the resolved exit plan mode request; clients should dismiss any UI for this + // request + RequestID *string `json:"requestId,omitempty"` + // Whether compaction completed successfully + // + // Whether the tool execution completed successfully + // + // Whether the hook completed successfully + Success *bool `json:"success,omitempty"` + // LLM-generated summary of the compacted conversation history + SummaryContent *string `json:"summaryContent,omitempty"` + // Number of tokens removed during compaction + TokensRemoved *float64 `json:"tokensRemoved,omitempty"` + // The agent mode that was active when this message was sent + AgentMode *AgentMode `json:"agentMode,omitempty"` + // Files, selections, or GitHub references attached to the message + Attachments []Attachment `json:"attachments,omitempty"` + // The user's message text as displayed in the timeline + // + // The complete extended thinking text from the model + // + // The assistant's text response content + // + // Full content of the skill file, injected into the conversation for the model + // + // The system or developer prompt text + Content *string `json:"content,omitempty"` + // CAPI interaction ID for correlating this user message with its turn + // + // CAPI interaction ID for correlating this turn with upstream telemetry + // + // CAPI interaction ID for correlating this message with upstream telemetry + // + // CAPI interaction ID for correlating this tool execution with upstream telemetry + InteractionID *string `json:"interactionId,omitempty"` + // Origin of this message, used for timeline filtering (e.g., "skill-pdf" for skill-injected + // messages that should be hidden from the user) + Source *string `json:"source,omitempty"` + // Transformed version of the message sent to the model, with XML wrapping, timestamps, and + // other augmentations for prompt caching + TransformedContent *string `json:"transformedContent,omitempty"` + // Identifier for this turn within the agentic loop, typically a stringified turn number + // + // Identifier of the turn that has ended, matching the corresponding assistant.turn_start + // event + TurnID *string `json:"turnId,omitempty"` + // Short description of what the agent is currently doing or planning to do + Intent *string `json:"intent,omitempty"` + // Unique identifier for this reasoning block + // + // Reasoning block ID this delta belongs to, matching the corresponding assistant.reasoning + // event + ReasoningID *string `json:"reasoningId,omitempty"` + // Incremental text chunk to append to the reasoning content + // + // Incremental text chunk to append to the message content + DeltaContent *string `json:"deltaContent,omitempty"` + // Cumulative total bytes received from the streaming response so far + TotalResponseSizeBytes *float64 `json:"totalResponseSizeBytes,omitempty"` + // Encrypted reasoning content from OpenAI models. Session-bound and stripped on resume. + EncryptedContent *string `json:"encryptedContent,omitempty"` + // Unique identifier for this assistant message + // + // Message ID this delta belongs to, matching the corresponding assistant.message event + MessageID *string `json:"messageId,omitempty"` + // Actual output token count from the API response (completion_tokens), used for accurate + // token accounting + // + // Number of output tokens produced + OutputTokens *float64 `json:"outputTokens,omitempty"` + // Tool call ID of the parent tool invocation when this event originates from a sub-agent + // + // Parent tool call ID when this usage originates from a sub-agent + ParentToolCallID *string `json:"parentToolCallId,omitempty"` + // Generation phase for phased-output models (e.g., thinking vs. response phases) + Phase *string `json:"phase,omitempty"` + // Opaque/encrypted extended thinking data from Anthropic models. Session-bound and stripped + // on resume. + ReasoningOpaque *string `json:"reasoningOpaque,omitempty"` + // Readable reasoning text from the model's extended thinking + ReasoningText *string `json:"reasoningText,omitempty"` + // Tool invocations requested by the assistant in this message + ToolRequests []ToolRequest `json:"toolRequests,omitempty"` + // Completion ID from the model provider (e.g., chatcmpl-abc123) + APICallID *string `json:"apiCallId,omitempty"` + // Number of tokens read from prompt cache + CacheReadTokens *float64 `json:"cacheReadTokens,omitempty"` + // Number of tokens written to prompt cache + CacheWriteTokens *float64 `json:"cacheWriteTokens,omitempty"` + // Per-request cost and usage data from the CAPI copilot_usage response field + CopilotUsage *CopilotUsage `json:"copilotUsage,omitempty"` + // Model multiplier cost for billing purposes + Cost *float64 `json:"cost,omitempty"` + // Duration of the API call in milliseconds + Duration *float64 `json:"duration,omitempty"` + // What initiated this API call (e.g., "sub-agent"); absent for user-initiated calls + Initiator *string `json:"initiator,omitempty"` + // Number of input tokens consumed + InputTokens *float64 `json:"inputTokens,omitempty"` + // Model identifier used for this API call + // + // Model identifier that generated this tool call + Model *string `json:"model,omitempty"` + // Per-quota resource usage snapshots, keyed by quota identifier + QuotaSnapshots map[string]QuotaSnapshot `json:"quotaSnapshots,omitempty"` + // Reason the current turn was aborted (e.g., "user initiated") + Reason *string `json:"reason,omitempty"` + // Arguments for the tool invocation + // + // Arguments passed to the tool + // + // Arguments to pass to the external tool + Arguments interface{} `json:"arguments"` + // Unique identifier for this tool call + // + // Tool call ID this partial result belongs to + // + // Tool call ID this progress notification belongs to + // + // Unique identifier for the completed tool call + // + // Tool call ID of the parent tool invocation that spawned this sub-agent + // + // Tool call ID assigned to this external tool invocation + ToolCallID *string `json:"toolCallId,omitempty"` + // Name of the tool the user wants to invoke + // + // Name of the tool being executed + // + // Name of the external tool to invoke + ToolName *string `json:"toolName,omitempty"` + // Name of the MCP server hosting this tool, when the tool is an MCP tool + MCPServerName *string `json:"mcpServerName,omitempty"` + // Original tool name on the MCP server, when the tool is an MCP tool + MCPToolName *string `json:"mcpToolName,omitempty"` + // Incremental output chunk from the running tool + PartialOutput *string `json:"partialOutput,omitempty"` + // Human-readable progress status message (e.g., from an MCP server) + ProgressMessage *string `json:"progressMessage,omitempty"` + // Whether this tool call was explicitly requested by the user rather than the assistant + IsUserRequested *bool `json:"isUserRequested,omitempty"` + // Tool execution result on success + // + // The result of the permission request + Result *Result `json:"result,omitempty"` + // Tool-specific telemetry data (e.g., CodeQL check counts, grep match counts) + ToolTelemetry map[string]interface{} `json:"toolTelemetry,omitempty"` + // Tool names that should be auto-approved when this skill is active + AllowedTools []string `json:"allowedTools,omitempty"` + // Name of the invoked skill + // + // Optional name identifier for the message source + Name *string `json:"name,omitempty"` + // Name of the plugin this skill originated from, when applicable + PluginName *string `json:"pluginName,omitempty"` + // Version of the plugin this skill originated from, when applicable + PluginVersion *string `json:"pluginVersion,omitempty"` + // Description of what the sub-agent does + AgentDescription *string `json:"agentDescription,omitempty"` + // Human-readable display name of the sub-agent + // + // Human-readable display name of the selected custom agent + AgentDisplayName *string `json:"agentDisplayName,omitempty"` + // Internal name of the sub-agent + // + // Internal name of the selected custom agent + AgentName *string `json:"agentName,omitempty"` + // List of tool names available to this agent, or null for all tools + Tools []string `json:"tools"` + // Unique identifier for this hook invocation + // + // Identifier matching the corresponding hook.start event + HookInvocationID *string `json:"hookInvocationId,omitempty"` + // Type of hook being invoked (e.g., "preToolUse", "postToolUse", "sessionStart") + // + // Type of hook that was invoked (e.g., "preToolUse", "postToolUse", "sessionStart") + HookType *string `json:"hookType,omitempty"` + // Input data passed to the hook + Input interface{} `json:"input"` + // Output data produced by the hook + Output interface{} `json:"output"` + // Metadata about the prompt template and its construction + Metadata *Metadata `json:"metadata,omitempty"` + // Message role: "system" for system prompts, "developer" for developer-injected instructions + Role *Role `json:"role,omitempty"` + // Details of the permission being requested + PermissionRequest *PermissionRequest `json:"permissionRequest,omitempty"` + // Whether the user can provide a free-form text response in addition to predefined choices + AllowFreeform *bool `json:"allowFreeform,omitempty"` + // Predefined choices for the user to select from, if applicable + Choices []string `json:"choices,omitempty"` + // The question or prompt to present to the user + Question *string `json:"question,omitempty"` + // Elicitation mode; currently only "form" is supported. Defaults to "form" when absent. + Mode *Mode `json:"mode,omitempty"` + // JSON Schema describing the form fields to present to the user + RequestedSchema *RequestedSchema `json:"requestedSchema,omitempty"` + // The slash command text to be executed (e.g., /help, /clear) + Command *string `json:"command,omitempty"` + // Available actions the user can take (e.g., approve, edit, reject) + Actions []string `json:"actions,omitempty"` + // Full content of the plan file + PlanContent *string `json:"planContent,omitempty"` + // The recommended action for the user to take + RecommendedAction *string `json:"recommendedAction,omitempty"` } type Attachment struct { - DisplayName string `json:"displayName"` - LineRange *LineRange `json:"lineRange,omitempty"` - Path *string `json:"path,omitempty"` - Type AttachmentType `json:"type"` - FilePath *string `json:"filePath,omitempty"` - Selection *SelectionClass `json:"selection,omitempty"` - Text *string `json:"text,omitempty"` + // User-facing display name for the attachment + // + // User-facing display name for the selection + DisplayName *string `json:"displayName,omitempty"` + // Optional line range to scope the attachment to a specific section of the file + LineRange *LineRange `json:"lineRange,omitempty"` + // Absolute file or directory path + Path *string `json:"path,omitempty"` + // Attachment type discriminator + Type AttachmentType `json:"type"` + // Absolute path to the file containing the selection + FilePath *string `json:"filePath,omitempty"` + // Position range of the selection within the file + Selection *SelectionClass `json:"selection,omitempty"` + // The selected text content + Text *string `json:"text,omitempty"` + // Issue, pull request, or discussion number + Number *float64 `json:"number,omitempty"` + // Type of GitHub reference + ReferenceType *ReferenceType `json:"referenceType,omitempty"` + // Current state of the referenced item (e.g., open, closed, merged) + State *string `json:"state,omitempty"` + // Title of the referenced item + Title *string `json:"title,omitempty"` + // URL to the referenced item on GitHub + URL *string `json:"url,omitempty"` } +// Optional line range to scope the attachment to a specific section of the file type LineRange struct { - End float64 `json:"end"` + // End line number (1-based, inclusive) + End float64 `json:"end"` + // Start line number (1-based) Start float64 `json:"start"` } +// Position range of the selection within the file type SelectionClass struct { End End `json:"end"` Start Start `json:"start"` } type End struct { + // End character offset within the line (0-based) Character float64 `json:"character"` - Line float64 `json:"line"` + // End line number (0-based) + Line float64 `json:"line"` } type Start struct { + // Start character offset within the line (0-based) Character float64 `json:"character"` - Line float64 `json:"line"` + // Start line number (0-based) + Line float64 `json:"line"` } +// Background tasks still running when the agent became idle +type BackgroundTasks struct { + // Currently running background agents + Agents []Agent `json:"agents"` + // Currently running background shell commands + Shells []Shell `json:"shells"` +} + +type Agent struct { + // Unique identifier of the background agent + AgentID string `json:"agentId"` + // Type of the background agent + AgentType string `json:"agentType"` + // Human-readable description of the agent task + Description *string `json:"description,omitempty"` +} + +type Shell struct { + // Human-readable description of the shell command + Description *string `json:"description,omitempty"` + // Unique identifier of the background shell + ShellID string `json:"shellId"` +} + +// Aggregate code change metrics for the session type CodeChanges struct { + // List of file paths that were modified during the session FilesModified []string `json:"filesModified"` - LinesAdded float64 `json:"linesAdded"` - LinesRemoved float64 `json:"linesRemoved"` + // Total number of lines added during the session + LinesAdded float64 `json:"linesAdded"` + // Total number of lines removed during the session + LinesRemoved float64 `json:"linesRemoved"` } +// Token usage breakdown for the compaction LLM call type CompactionTokensUsed struct { + // Cached input tokens reused in the compaction LLM call CachedInput float64 `json:"cachedInput"` - Input float64 `json:"input"` - Output float64 `json:"output"` + // Input tokens consumed by the compaction LLM call + Input float64 `json:"input"` + // Output tokens produced by the compaction LLM call + Output float64 `json:"output"` } +// Working directory and git context at session start +// +// Updated working directory and git context at resume time type ContextClass struct { - Branch *string `json:"branch,omitempty"` - Cwd string `json:"cwd"` - GitRoot *string `json:"gitRoot,omitempty"` + // Current git branch name + Branch *string `json:"branch,omitempty"` + // Current working directory path + Cwd string `json:"cwd"` + // Root directory of the git repository, resolved via git rev-parse + GitRoot *string `json:"gitRoot,omitempty"` + // Repository identifier in "owner/name" format, derived from the git remote URL Repository *string `json:"repository,omitempty"` } +// Per-request cost and usage data from the CAPI copilot_usage response field +type CopilotUsage struct { + // Itemized token usage breakdown + TokenDetails []TokenDetail `json:"tokenDetails"` + // Total cost in nano-AIU (AI Units) for this request + TotalNanoAiu float64 `json:"totalNanoAiu"` +} + +type TokenDetail struct { + // Number of tokens in this billing batch + BatchSize float64 `json:"batchSize"` + // Cost per batch of tokens + CostPerBatch float64 `json:"costPerBatch"` + // Total token count for this entry + TokenCount float64 `json:"tokenCount"` + // Token category (e.g., "input", "output") + TokenType string `json:"tokenType"` +} + +// Error details when the tool execution failed +// +// Error details when the hook failed type ErrorClass struct { - Code *string `json:"code,omitempty"` - Message string `json:"message"` - Stack *string `json:"stack,omitempty"` + // Machine-readable error code + Code *string `json:"code,omitempty"` + // Human-readable error message + Message string `json:"message"` + // Error stack trace, when available + Stack *string `json:"stack,omitempty"` } +// Metadata about the prompt template and its construction type Metadata struct { - PromptVersion *string `json:"promptVersion,omitempty"` - Variables map[string]interface{} `json:"variables,omitempty"` + // Version identifier of the prompt template used + PromptVersion *string `json:"promptVersion,omitempty"` + // Template variables used when constructing the prompt + Variables map[string]interface{} `json:"variables,omitempty"` } type ModelMetric struct { + // Request count and cost metrics Requests Requests `json:"requests"` - Usage Usage `json:"usage"` + // Token usage breakdown + Usage Usage `json:"usage"` } +// Request count and cost metrics type Requests struct { - Cost float64 `json:"cost"` + // Cumulative cost multiplier for requests to this model + Cost float64 `json:"cost"` + // Total number of API requests made to this model Count float64 `json:"count"` } +// Token usage breakdown type Usage struct { - CacheReadTokens float64 `json:"cacheReadTokens"` + // Total tokens read from prompt cache across all requests + CacheReadTokens float64 `json:"cacheReadTokens"` + // Total tokens written to prompt cache across all requests CacheWriteTokens float64 `json:"cacheWriteTokens"` - InputTokens float64 `json:"inputTokens"` - OutputTokens float64 `json:"outputTokens"` + // Total input tokens consumed across all requests to this model + InputTokens float64 `json:"inputTokens"` + // Total output tokens produced across all requests to this model + OutputTokens float64 `json:"outputTokens"` +} + +// Details of the permission being requested +type PermissionRequest struct { + // Whether the UI can offer session-wide approval for this command pattern + CanOfferSessionApproval *bool `json:"canOfferSessionApproval,omitempty"` + // Parsed command identifiers found in the command text + Commands []Command `json:"commands,omitempty"` + // The complete shell command text to be executed + FullCommandText *string `json:"fullCommandText,omitempty"` + // Whether the command includes a file write redirection (e.g., > or >>) + HasWriteFileRedirection *bool `json:"hasWriteFileRedirection,omitempty"` + // Human-readable description of what the command intends to do + // + // Human-readable description of the intended file change + // + // Human-readable description of why the file is being read + // + // Human-readable description of why the URL is being accessed + Intention *string `json:"intention,omitempty"` + // Permission kind discriminator + Kind PermissionRequestKind `json:"kind"` + // File paths that may be read or written by the command + PossiblePaths []string `json:"possiblePaths,omitempty"` + // URLs that may be accessed by the command + PossibleUrls []PossibleURL `json:"possibleUrls,omitempty"` + // Tool call ID that triggered this permission request + ToolCallID *string `json:"toolCallId,omitempty"` + // Optional warning message about risks of running this command + Warning *string `json:"warning,omitempty"` + // Unified diff showing the proposed changes + Diff *string `json:"diff,omitempty"` + // Path of the file being written to + FileName *string `json:"fileName,omitempty"` + // Complete new file contents for newly created files + NewFileContents *string `json:"newFileContents,omitempty"` + // Path of the file or directory being read + Path *string `json:"path,omitempty"` + // Arguments to pass to the MCP tool + // + // Arguments to pass to the custom tool + Args interface{} `json:"args"` + // Whether this MCP tool is read-only (no side effects) + ReadOnly *bool `json:"readOnly,omitempty"` + // Name of the MCP server providing the tool + ServerName *string `json:"serverName,omitempty"` + // Internal name of the MCP tool + // + // Name of the custom tool + ToolName *string `json:"toolName,omitempty"` + // Human-readable title of the MCP tool + ToolTitle *string `json:"toolTitle,omitempty"` + // URL to be fetched + URL *string `json:"url,omitempty"` + // Source references for the stored fact + Citations *string `json:"citations,omitempty"` + // The fact or convention being stored + Fact *string `json:"fact,omitempty"` + // Topic or subject of the memory being stored + Subject *string `json:"subject,omitempty"` + // Description of what the custom tool does + ToolDescription *string `json:"toolDescription,omitempty"` +} + +type Command struct { + // Command identifier (e.g., executable name) + Identifier string `json:"identifier"` + // Whether this command is read-only (no side effects) + ReadOnly bool `json:"readOnly"` +} + +type PossibleURL struct { + // URL that may be accessed by the command + URL string `json:"url"` } type QuotaSnapshot struct { - EntitlementRequests float64 `json:"entitlementRequests"` - IsUnlimitedEntitlement bool `json:"isUnlimitedEntitlement"` - Overage float64 `json:"overage"` - OverageAllowedWithExhaustedQuota bool `json:"overageAllowedWithExhaustedQuota"` - RemainingPercentage float64 `json:"remainingPercentage"` - ResetDate *time.Time `json:"resetDate,omitempty"` - UsageAllowedWithExhaustedQuota bool `json:"usageAllowedWithExhaustedQuota"` - UsedRequests float64 `json:"usedRequests"` + // Total requests allowed by the entitlement + EntitlementRequests float64 `json:"entitlementRequests"` + // Whether the user has an unlimited usage entitlement + IsUnlimitedEntitlement bool `json:"isUnlimitedEntitlement"` + // Number of requests over the entitlement limit + Overage float64 `json:"overage"` + // Whether overage is allowed when quota is exhausted + OverageAllowedWithExhaustedQuota bool `json:"overageAllowedWithExhaustedQuota"` + // Percentage of quota remaining (0.0 to 1.0) + RemainingPercentage float64 `json:"remainingPercentage"` + // Date when the quota resets + ResetDate *time.Time `json:"resetDate,omitempty"` + // Whether usage is still permitted after quota exhaustion + UsageAllowedWithExhaustedQuota bool `json:"usageAllowedWithExhaustedQuota"` + // Number of requests already consumed + UsedRequests float64 `json:"usedRequests"` } +// Repository context for the handed-off session type RepositoryClass struct { + // Git branch name, if applicable Branch *string `json:"branch,omitempty"` - Name string `json:"name"` - Owner string `json:"owner"` + // Repository name + Name string `json:"name"` + // Repository owner (user or organization) + Owner string `json:"owner"` +} + +// JSON Schema describing the form fields to present to the user +type RequestedSchema struct { + // Form field definitions, keyed by field name + Properties map[string]interface{} `json:"properties"` + // List of required field names + Required []string `json:"required,omitempty"` + Type RequestedSchemaType `json:"type"` } +// Tool execution result on success +// +// The result of the permission request type Result struct { - Content string `json:"content"` - Contents []Content `json:"contents,omitempty"` - DetailedContent *string `json:"detailedContent,omitempty"` + // Concise tool result text sent to the LLM for chat completion, potentially truncated for + // token efficiency + Content *string `json:"content,omitempty"` + // Structured content blocks (text, images, audio, resources) returned by the tool in their + // native format + Contents []Content `json:"contents,omitempty"` + // Full detailed tool result for UI/timeline display, preserving complete content such as + // diffs. Falls back to content when absent. + DetailedContent *string `json:"detailedContent,omitempty"` + // The outcome of the permission request + Kind *ResultKind `json:"kind,omitempty"` } type Content struct { - Text *string `json:"text,omitempty"` - Type ContentType `json:"type"` - Cwd *string `json:"cwd,omitempty"` - ExitCode *float64 `json:"exitCode,omitempty"` - Data *string `json:"data,omitempty"` - MIMEType *string `json:"mimeType,omitempty"` - Description *string `json:"description,omitempty"` - Icons []Icon `json:"icons,omitempty"` - Name *string `json:"name,omitempty"` - Size *float64 `json:"size,omitempty"` - Title *string `json:"title,omitempty"` - URI *string `json:"uri,omitempty"` - Resource *ResourceClass `json:"resource,omitempty"` + // The text content + // + // Terminal/shell output text + Text *string `json:"text,omitempty"` + // Content block type discriminator + Type ContentType `json:"type"` + // Working directory where the command was executed + Cwd *string `json:"cwd,omitempty"` + // Process exit code, if the command has completed + ExitCode *float64 `json:"exitCode,omitempty"` + // Base64-encoded image data + // + // Base64-encoded audio data + Data *string `json:"data,omitempty"` + // MIME type of the image (e.g., image/png, image/jpeg) + // + // MIME type of the audio (e.g., audio/wav, audio/mpeg) + // + // MIME type of the resource content + MIMEType *string `json:"mimeType,omitempty"` + // Human-readable description of the resource + Description *string `json:"description,omitempty"` + // Icons associated with this resource + Icons []Icon `json:"icons,omitempty"` + // Resource name identifier + Name *string `json:"name,omitempty"` + // Size of the resource in bytes + Size *float64 `json:"size,omitempty"` + // Human-readable display title for the resource + Title *string `json:"title,omitempty"` + // URI identifying the resource + URI *string `json:"uri,omitempty"` + // The embedded resource contents, either text or base64-encoded binary + Resource *ResourceClass `json:"resource,omitempty"` } type Icon struct { - MIMEType *string `json:"mimeType,omitempty"` - Sizes []string `json:"sizes,omitempty"` - Src string `json:"src"` - Theme *Theme `json:"theme,omitempty"` + // MIME type of the icon image + MIMEType *string `json:"mimeType,omitempty"` + // Available icon sizes (e.g., ['16x16', '32x32']) + Sizes []string `json:"sizes,omitempty"` + // URL or path to the icon image + Src string `json:"src"` + // Theme variant this icon is intended for + Theme *Theme `json:"theme,omitempty"` } +// The embedded resource contents, either text or base64-encoded binary type ResourceClass struct { + // MIME type of the text content + // + // MIME type of the blob content MIMEType *string `json:"mimeType,omitempty"` - Text *string `json:"text,omitempty"` - URI string `json:"uri"` - Blob *string `json:"blob,omitempty"` + // Text content of the resource + Text *string `json:"text,omitempty"` + // URI identifying the resource + URI string `json:"uri"` + // Base64-encoded binary content of the resource + Blob *string `json:"blob,omitempty"` } type ToolRequest struct { - Arguments interface{} `json:"arguments"` - Name string `json:"name"` - ToolCallID string `json:"toolCallId"` - Type *ToolRequestType `json:"type,omitempty"` + // Arguments to pass to the tool, format depends on the tool + Arguments interface{} `json:"arguments"` + // Name of the tool being invoked + Name string `json:"name"` + // Unique identifier for this tool call + ToolCallID string `json:"toolCallId"` + // Tool call type: "function" for standard tool calls, "custom" for grammar-based tool + // calls. Defaults to "function" when absent. + Type *ToolRequestType `json:"type,omitempty"` } +// The agent mode that was active when this message was sent type AgentMode string const ( - Autopilot AgentMode = "autopilot" - Interactive AgentMode = "interactive" - Plan AgentMode = "plan" - Shell AgentMode = "shell" + AgentModeShell AgentMode = "shell" + Autopilot AgentMode = "autopilot" + Interactive AgentMode = "interactive" + Plan AgentMode = "plan" +) + +// Type of GitHub reference +type ReferenceType string + +const ( + Discussion ReferenceType = "discussion" + Issue ReferenceType = "issue" + PR ReferenceType = "pr" ) type AttachmentType string const ( - Directory AttachmentType = "directory" - File AttachmentType = "file" - Selection AttachmentType = "selection" + Directory AttachmentType = "directory" + File AttachmentType = "file" + GithubReference AttachmentType = "github_reference" + Selection AttachmentType = "selection" +) + +type Mode string + +const ( + Form Mode = "form" ) +// The type of operation performed on the plan file +// +// Whether the file was newly created or updated type Operation string const ( @@ -313,6 +877,25 @@ const ( Update Operation = "update" ) +type PermissionRequestKind string + +const ( + CustomTool PermissionRequestKind = "custom-tool" + KindShell PermissionRequestKind = "shell" + MCP PermissionRequestKind = "mcp" + Memory PermissionRequestKind = "memory" + Read PermissionRequestKind = "read" + URL PermissionRequestKind = "url" + Write PermissionRequestKind = "write" +) + +type RequestedSchemaType string + +const ( + Object RequestedSchemaType = "object" +) + +// Theme variant this icon is intended for type Theme string const ( @@ -331,6 +914,18 @@ const ( Text ContentType = "text" ) +// The outcome of the permission request +type ResultKind string + +const ( + Approved ResultKind = "approved" + DeniedByContentExclusionPolicy ResultKind = "denied-by-content-exclusion-policy" + DeniedByRules ResultKind = "denied-by-rules" + DeniedInteractivelyByUser ResultKind = "denied-interactively-by-user" + DeniedNoApprovalRuleAndCouldNotRequestFromUser ResultKind = "denied-no-approval-rule-and-could-not-request-from-user" +) + +// Message role: "system" for system prompts, "developer" for developer-injected instructions type Role string const ( @@ -338,6 +933,7 @@ const ( System Role = "system" ) +// Whether the session ended normally ("routine") or due to a crash/fatal error ("error") type ShutdownType string const ( @@ -345,6 +941,7 @@ const ( Routine ShutdownType = "routine" ) +// Origin type of the session being handed off type SourceType string const ( @@ -352,6 +949,8 @@ const ( Remote SourceType = "remote" ) +// Tool call type: "function" for standard tool calls, "custom" for grammar-based tool +// calls. Defaults to "function" when absent. type ToolRequestType string const ( @@ -368,12 +967,23 @@ const ( AssistantMessageDelta SessionEventType = "assistant.message_delta" AssistantReasoning SessionEventType = "assistant.reasoning" AssistantReasoningDelta SessionEventType = "assistant.reasoning_delta" + AssistantStreamingDelta SessionEventType = "assistant.streaming_delta" AssistantTurnEnd SessionEventType = "assistant.turn_end" AssistantTurnStart SessionEventType = "assistant.turn_start" AssistantUsage SessionEventType = "assistant.usage" + CommandCompleted SessionEventType = "command.completed" + CommandQueued SessionEventType = "command.queued" + ElicitationCompleted SessionEventType = "elicitation.completed" + ElicitationRequested SessionEventType = "elicitation.requested" + ExitPlanModeCompleted SessionEventType = "exit_plan_mode.completed" + ExitPlanModeRequested SessionEventType = "exit_plan_mode.requested" + ExternalToolCompleted SessionEventType = "external_tool.completed" + ExternalToolRequested SessionEventType = "external_tool.requested" HookEnd SessionEventType = "hook.end" HookStart SessionEventType = "hook.start" PendingMessagesModified SessionEventType = "pending_messages.modified" + PermissionCompleted SessionEventType = "permission.completed" + PermissionRequested SessionEventType = "permission.requested" SessionCompactionComplete SessionEventType = "session.compaction_complete" SessionCompactionStart SessionEventType = "session.compaction_start" SessionContextChanged SessionEventType = "session.context_changed" @@ -388,6 +998,7 @@ const ( SessionShutdown SessionEventType = "session.shutdown" SessionSnapshotRewind SessionEventType = "session.snapshot_rewind" SessionStart SessionEventType = "session.start" + SessionTaskComplete SessionEventType = "session.task_complete" SessionTitleChanged SessionEventType = "session.title_changed" SessionTruncation SessionEventType = "session.truncation" SessionUsageInfo SessionEventType = "session.usage_info" @@ -395,6 +1006,7 @@ const ( SessionWorkspaceFileChanged SessionEventType = "session.workspace_file_changed" SkillInvoked SessionEventType = "skill.invoked" SubagentCompleted SessionEventType = "subagent.completed" + SubagentDeselected SessionEventType = "subagent.deselected" SubagentFailed SessionEventType = "subagent.failed" SubagentSelected SessionEventType = "subagent.selected" SubagentStarted SessionEventType = "subagent.started" @@ -404,6 +1016,8 @@ const ( ToolExecutionProgress SessionEventType = "tool.execution_progress" ToolExecutionStart SessionEventType = "tool.execution_start" ToolUserRequested SessionEventType = "tool.user_requested" + UserInputCompleted SessionEventType = "user_input.completed" + UserInputRequested SessionEventType = "user_input.requested" UserMessage SessionEventType = "user.message" ) diff --git a/go/internal/e2e/agent_and_compact_rpc_test.go b/go/internal/e2e/agent_and_compact_rpc_test.go new file mode 100644 index 000000000..338f4da67 --- /dev/null +++ b/go/internal/e2e/agent_and_compact_rpc_test.go @@ -0,0 +1,289 @@ +package e2e + +import ( + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestAgentSelectionRpc(t *testing.T) { + cliPath := testharness.CLIPath() + if cliPath == "" { + t.Fatal("CLI not found. Run 'npm install' in the nodejs directory first.") + } + + t.Run("should list available custom agents", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "test-agent", + DisplayName: "Test Agent", + Description: "A test agent", + Prompt: "You are a test agent.", + }, + { + Name: "another-agent", + DisplayName: "Another Agent", + Description: "Another test agent", + Prompt: "You are another agent.", + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + result, err := session.RPC.Agent.List(t.Context()) + if err != nil { + t.Fatalf("Failed to list agents: %v", err) + } + + if len(result.Agents) != 2 { + t.Fatalf("Expected 2 agents, got %d", len(result.Agents)) + } + if result.Agents[0].Name != "test-agent" { + t.Errorf("Expected first agent name 'test-agent', got %q", result.Agents[0].Name) + } + if result.Agents[0].DisplayName != "Test Agent" { + t.Errorf("Expected first agent displayName 'Test Agent', got %q", result.Agents[0].DisplayName) + } + if result.Agents[1].Name != "another-agent" { + t.Errorf("Expected second agent name 'another-agent', got %q", result.Agents[1].Name) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + t.Run("should return null when no agent is selected", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "test-agent", + DisplayName: "Test Agent", + Description: "A test agent", + Prompt: "You are a test agent.", + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + result, err := session.RPC.Agent.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to get current agent: %v", err) + } + + if result.Agent != nil { + t.Errorf("Expected no agent selected, got %v", result.Agent) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + t.Run("should select and get current agent", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "test-agent", + DisplayName: "Test Agent", + Description: "A test agent", + Prompt: "You are a test agent.", + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Select the agent + selectResult, err := session.RPC.Agent.Select(t.Context(), &rpc.SessionAgentSelectParams{Name: "test-agent"}) + if err != nil { + t.Fatalf("Failed to select agent: %v", err) + } + if selectResult.Agent.Name != "test-agent" { + t.Errorf("Expected selected agent 'test-agent', got %q", selectResult.Agent.Name) + } + if selectResult.Agent.DisplayName != "Test Agent" { + t.Errorf("Expected displayName 'Test Agent', got %q", selectResult.Agent.DisplayName) + } + + // Verify getCurrent returns the selected agent + currentResult, err := session.RPC.Agent.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to get current agent: %v", err) + } + if currentResult.Agent == nil { + t.Fatal("Expected an agent to be selected") + } + if currentResult.Agent.Name != "test-agent" { + t.Errorf("Expected current agent 'test-agent', got %q", currentResult.Agent.Name) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + t.Run("should deselect current agent", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: []copilot.CustomAgentConfig{ + { + Name: "test-agent", + DisplayName: "Test Agent", + Description: "A test agent", + Prompt: "You are a test agent.", + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Select then deselect + _, err = session.RPC.Agent.Select(t.Context(), &rpc.SessionAgentSelectParams{Name: "test-agent"}) + if err != nil { + t.Fatalf("Failed to select agent: %v", err) + } + + _, err = session.RPC.Agent.Deselect(t.Context()) + if err != nil { + t.Fatalf("Failed to deselect agent: %v", err) + } + + // Verify no agent is selected + currentResult, err := session.RPC.Agent.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Failed to get current agent: %v", err) + } + if currentResult.Agent != nil { + t.Errorf("Expected no agent selected after deselect, got %v", currentResult.Agent) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) + + t.Run("should return empty list when no custom agents configured", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + result, err := session.RPC.Agent.List(t.Context()) + if err != nil { + t.Fatalf("Failed to list agents: %v", err) + } + + if len(result.Agents) != 0 { + t.Errorf("Expected empty agent list, got %d agents", len(result.Agents)) + } + + if err := client.Stop(); err != nil { + t.Errorf("Expected no errors on stop, got %v", err) + } + }) +} + +func TestSessionCompactionRpc(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + t.Run("should compact session history after messages", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Send a message to create some history + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "What is 2+2?", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // Compact the session + result, err := session.RPC.Compaction.Compact(t.Context()) + if err != nil { + t.Fatalf("Failed to compact session: %v", err) + } + + // Verify result has expected fields (just check it returned valid data) + if result == nil { + t.Fatal("Expected non-nil compact result") + } + }) +} diff --git a/go/internal/e2e/ask_user_test.go b/go/internal/e2e/ask_user_test.go index 305d9df8a..d5458483a 100644 --- a/go/internal/e2e/ask_user_test.go +++ b/go/internal/e2e/ask_user_test.go @@ -20,6 +20,7 @@ func TestAskUser(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, OnUserInputRequest: func(request copilot.UserInputRequest, invocation copilot.UserInputInvocation) (copilot.UserInputResponse, error) { mu.Lock() userInputRequests = append(userInputRequests, request) @@ -80,6 +81,7 @@ func TestAskUser(t *testing.T) { var mu sync.Mutex session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, OnUserInputRequest: func(request copilot.UserInputRequest, invocation copilot.UserInputInvocation) (copilot.UserInputResponse, error) { mu.Lock() userInputRequests = append(userInputRequests, request) @@ -135,6 +137,7 @@ func TestAskUser(t *testing.T) { freeformAnswer := "This is my custom freeform answer that was not in the choices" session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, OnUserInputRequest: func(request copilot.UserInputRequest, invocation copilot.UserInputInvocation) (copilot.UserInputResponse, error) { mu.Lock() userInputRequests = append(userInputRequests, request) diff --git a/go/internal/e2e/client_test.go b/go/internal/e2e/client_test.go index 8f5cf2495..d2663d2fa 100644 --- a/go/internal/e2e/client_test.go +++ b/go/internal/e2e/client_test.go @@ -94,7 +94,9 @@ func TestClient(t *testing.T) { }) t.Cleanup(func() { client.ForceStop() }) - _, err := client.CreateSession(t.Context(), nil) + _, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -118,7 +120,9 @@ func TestClient(t *testing.T) { }) t.Cleanup(func() { client.ForceStop() }) - _, err := client.CreateSession(t.Context(), nil) + _, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to create session: %v", err) } diff --git a/go/internal/e2e/compaction_test.go b/go/internal/e2e/compaction_test.go index da9ea240c..aee80704d 100644 --- a/go/internal/e2e/compaction_test.go +++ b/go/internal/e2e/compaction_test.go @@ -21,6 +21,7 @@ func TestCompaction(t *testing.T) { bufferThreshold := 0.01 // 1% session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, InfiniteSessions: &copilot.InfiniteSessionConfig{ Enabled: &enabled, BackgroundCompactionThreshold: &backgroundThreshold, @@ -44,7 +45,7 @@ func TestCompaction(t *testing.T) { }) // Send multiple messages to fill up the context window - _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Tell me a long story about a dragon. Be very detailed."}) + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Tell me a story about a dragon. Be detailed."}) if err != nil { t.Fatalf("Failed to send first message: %v", err) } @@ -93,6 +94,7 @@ func TestCompaction(t *testing.T) { enabled := false session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, InfiniteSessions: &copilot.InfiniteSessionConfig{ Enabled: &enabled, }, diff --git a/go/internal/e2e/mcp_and_agents_test.go b/go/internal/e2e/mcp_and_agents_test.go index f8325b9f4..079d26e9f 100644 --- a/go/internal/e2e/mcp_and_agents_test.go +++ b/go/internal/e2e/mcp_and_agents_test.go @@ -27,7 +27,8 @@ func TestMCPServers(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - MCPServers: mcpServers, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: mcpServers, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -54,14 +55,14 @@ func TestMCPServers(t *testing.T) { t.Errorf("Expected message to contain '4', got: %v", message.Data.Content) } - session.Destroy() + session.Disconnect() }) t.Run("accept MCP server config on resume", func(t *testing.T) { ctx.ConfigureForTest(t) // Create a session first - session1, err := client.CreateSession(t.Context(), nil) + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -83,7 +84,8 @@ func TestMCPServers(t *testing.T) { } session2, err := client.ResumeSessionWithOptions(t.Context(), sessionID, &copilot.ResumeSessionConfig{ - MCPServers: mcpServers, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: mcpServers, }) if err != nil { t.Fatalf("Failed to resume session: %v", err) @@ -102,7 +104,7 @@ func TestMCPServers(t *testing.T) { t.Errorf("Expected message to contain '6', got: %v", message.Data.Content) } - session2.Destroy() + session2.Disconnect() }) t.Run("should pass literal env values to MCP server subprocess", func(t *testing.T) { @@ -148,7 +150,7 @@ func TestMCPServers(t *testing.T) { t.Errorf("Expected message to contain 'hunter2', got: %v", message.Data.Content) } - session.Destroy() + session.Disconnect() }) t.Run("handle multiple MCP servers", func(t *testing.T) { @@ -170,7 +172,8 @@ func TestMCPServers(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - MCPServers: mcpServers, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: mcpServers, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -180,7 +183,7 @@ func TestMCPServers(t *testing.T) { t.Error("Expected non-empty session ID") } - session.Destroy() + session.Disconnect() }) } @@ -204,7 +207,8 @@ func TestCustomAgents(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - CustomAgents: customAgents, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: customAgents, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -231,14 +235,14 @@ func TestCustomAgents(t *testing.T) { t.Errorf("Expected message to contain '10', got: %v", message.Data.Content) } - session.Destroy() + session.Disconnect() }) t.Run("accept custom agent config on resume", func(t *testing.T) { ctx.ConfigureForTest(t) // Create a session first - session1, err := client.CreateSession(t.Context(), nil) + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -260,7 +264,8 @@ func TestCustomAgents(t *testing.T) { } session2, err := client.ResumeSessionWithOptions(t.Context(), sessionID, &copilot.ResumeSessionConfig{ - CustomAgents: customAgents, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: customAgents, }) if err != nil { t.Fatalf("Failed to resume session: %v", err) @@ -279,7 +284,7 @@ func TestCustomAgents(t *testing.T) { t.Errorf("Expected message to contain '12', got: %v", message.Data.Content) } - session2.Destroy() + session2.Disconnect() }) t.Run("handle custom agent with tools", func(t *testing.T) { @@ -298,7 +303,8 @@ func TestCustomAgents(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - CustomAgents: customAgents, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: customAgents, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -308,7 +314,7 @@ func TestCustomAgents(t *testing.T) { t.Error("Expected non-empty session ID") } - session.Destroy() + session.Disconnect() }) t.Run("handle custom agent with MCP servers", func(t *testing.T) { @@ -332,7 +338,8 @@ func TestCustomAgents(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - CustomAgents: customAgents, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: customAgents, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -342,7 +349,7 @@ func TestCustomAgents(t *testing.T) { t.Error("Expected non-empty session ID") } - session.Destroy() + session.Disconnect() }) t.Run("handle multiple custom agents", func(t *testing.T) { @@ -368,7 +375,8 @@ func TestCustomAgents(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - CustomAgents: customAgents, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + CustomAgents: customAgents, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -378,7 +386,7 @@ func TestCustomAgents(t *testing.T) { t.Error("Expected non-empty session ID") } - session.Destroy() + session.Disconnect() }) } @@ -409,8 +417,9 @@ func TestCombinedConfiguration(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - MCPServers: mcpServers, - CustomAgents: customAgents, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: mcpServers, + CustomAgents: customAgents, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -436,6 +445,6 @@ func TestCombinedConfiguration(t *testing.T) { t.Errorf("Expected message to contain '14', got: %v", message.Data.Content) } - session.Destroy() + session.Disconnect() }) } diff --git a/go/internal/e2e/multi_client_test.go b/go/internal/e2e/multi_client_test.go new file mode 100644 index 000000000..9571ab58e --- /dev/null +++ b/go/internal/e2e/multi_client_test.go @@ -0,0 +1,498 @@ +package e2e + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" +) + +func TestMultiClient(t *testing.T) { + // Use TCP mode so a second client can connect to the same CLI process + ctx := testharness.NewTestContext(t) + client1 := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: ctx.CLIPath, + Cwd: ctx.WorkDir, + Env: ctx.Env(), + UseStdio: copilot.Bool(false), + }) + t.Cleanup(func() { client1.ForceStop() }) + + // Trigger connection so we can read the port + initSession, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create init session: %v", err) + } + initSession.Disconnect() + + actualPort := client1.ActualPort() + if actualPort == 0 { + t.Fatalf("Expected non-zero port from TCP mode client") + } + + client2 := copilot.NewClient(&copilot.ClientOptions{ + CLIUrl: fmt.Sprintf("localhost:%d", actualPort), + }) + t.Cleanup(func() { client2.ForceStop() }) + + t.Run("both clients see tool request and completion events", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type SeedParams struct { + Seed string `json:"seed" jsonschema:"A seed value"` + } + + tool := copilot.DefineTool("magic_number", "Returns a magic number", + func(params SeedParams, inv copilot.ToolInvocation) (string, error) { + return fmt.Sprintf("MAGIC_%s_42", params.Seed), nil + }) + + // Client 1 creates a session with a custom tool + session1, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{tool}, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Client 2 resumes with NO tools — should not overwrite client 1's tools + session2, err := client2.ResumeSession(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to resume session: %v", err) + } + + // Set up event waiters BEFORE sending the prompt to avoid race conditions + client1Requested := make(chan struct{}, 1) + client2Requested := make(chan struct{}, 1) + client1Completed := make(chan struct{}, 1) + client2Completed := make(chan struct{}, 1) + + session1.On(func(event copilot.SessionEvent) { + if event.Type == copilot.ExternalToolRequested { + select { + case client1Requested <- struct{}{}: + default: + } + } + if event.Type == copilot.ExternalToolCompleted { + select { + case client1Completed <- struct{}{}: + default: + } + } + }) + session2.On(func(event copilot.SessionEvent) { + if event.Type == copilot.ExternalToolRequested { + select { + case client2Requested <- struct{}{}: + default: + } + } + if event.Type == copilot.ExternalToolCompleted { + select { + case client2Completed <- struct{}{}: + default: + } + } + }) + + // Send a prompt that triggers the custom tool + response, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the magic_number tool with seed 'hello' and tell me the result", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + if response == nil || response.Data.Content == nil || !strings.Contains(*response.Data.Content, "MAGIC_hello_42") { + t.Errorf("Expected response to contain 'MAGIC_hello_42', got %v", response) + } + + // Wait for all broadcast events to arrive on both clients + timeout := time.After(10 * time.Second) + for _, ch := range []chan struct{}{client1Requested, client2Requested, client1Completed, client2Completed} { + select { + case <-ch: + case <-timeout: + t.Fatal("Timed out waiting for broadcast events on both clients") + } + } + + session2.Disconnect() + }) + + t.Run("one client approves permission and both see the result", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var client1PermissionRequests []copilot.PermissionRequest + var mu sync.Mutex + + // Client 1 creates a session and manually approves permission requests + session1, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + mu.Lock() + client1PermissionRequests = append(client1PermissionRequests, request) + mu.Unlock() + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Client 2 resumes — its handler never resolves, so only client 1's approval takes effect + session2, err := client2.ResumeSession(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + // Block forever so only client 1's handler responds + select {} + }, + }) + if err != nil { + t.Fatalf("Failed to resume session: %v", err) + } + + // Track events + var client1Events, client2Events []copilot.SessionEvent + var mu1, mu2 sync.Mutex + session1.On(func(event copilot.SessionEvent) { + mu1.Lock() + client1Events = append(client1Events, event) + mu1.Unlock() + }) + session2.On(func(event copilot.SessionEvent) { + mu2.Lock() + client2Events = append(client2Events, event) + mu2.Unlock() + }) + + // Send a prompt that triggers a write operation (requires permission) + response, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Create a file called hello.txt containing the text 'hello world'", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if response == nil || response.Data.Content == nil || *response.Data.Content == "" { + t.Errorf("Expected non-empty response") + } + + // Client 1 should have handled the permission request + mu.Lock() + permCount := len(client1PermissionRequests) + mu.Unlock() + if permCount == 0 { + t.Errorf("Expected client 1 to handle at least one permission request") + } + + // Both clients should have seen permission.requested events + mu1.Lock() + c1PermRequested := filterEventsByType(client1Events, copilot.PermissionRequested) + mu1.Unlock() + mu2.Lock() + c2PermRequested := filterEventsByType(client2Events, copilot.PermissionRequested) + mu2.Unlock() + + if len(c1PermRequested) == 0 { + t.Errorf("Expected client 1 to see permission.requested events") + } + if len(c2PermRequested) == 0 { + t.Errorf("Expected client 2 to see permission.requested events") + } + + // Both clients should have seen permission.completed events with approved result + mu1.Lock() + c1PermCompleted := filterEventsByType(client1Events, copilot.PermissionCompleted) + mu1.Unlock() + mu2.Lock() + c2PermCompleted := filterEventsByType(client2Events, copilot.PermissionCompleted) + mu2.Unlock() + + if len(c1PermCompleted) == 0 { + t.Errorf("Expected client 1 to see permission.completed events") + } + if len(c2PermCompleted) == 0 { + t.Errorf("Expected client 2 to see permission.completed events") + } + for _, event := range append(c1PermCompleted, c2PermCompleted...) { + if event.Data.Result == nil || event.Data.Result.Kind == nil || *event.Data.Result.Kind != "approved" { + t.Errorf("Expected permission.completed result kind 'approved', got %v", event.Data.Result) + } + } + + session2.Disconnect() + }) + + t.Run("one client rejects permission and both see the result", func(t *testing.T) { + ctx.ConfigureForTest(t) + + // Client 1 creates a session and denies all permission requests + session1, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindDeniedInteractivelyByUser}, nil + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Client 2 resumes — its handler never resolves so only client 1's denial takes effect + session2, err := client2.ResumeSession(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + select {} + }, + }) + if err != nil { + t.Fatalf("Failed to resume session: %v", err) + } + + var client1Events, client2Events []copilot.SessionEvent + var mu1, mu2 sync.Mutex + session1.On(func(event copilot.SessionEvent) { + mu1.Lock() + client1Events = append(client1Events, event) + mu1.Unlock() + }) + session2.On(func(event copilot.SessionEvent) { + mu2.Lock() + client2Events = append(client2Events, event) + mu2.Unlock() + }) + + // Write a test file and ask the agent to edit it + testFile := filepath.Join(ctx.WorkDir, "protected.txt") + if err := os.WriteFile(testFile, []byte("protected content"), 0644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + _, err = session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Edit protected.txt and replace 'protected' with 'hacked'.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // Verify the file was NOT modified (permission was denied) + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read test file: %v", err) + } + if string(content) != "protected content" { + t.Errorf("Expected file content 'protected content', got '%s'", string(content)) + } + + // Both clients should have seen permission.requested events + mu1.Lock() + c1PermRequested := filterEventsByType(client1Events, copilot.PermissionRequested) + mu1.Unlock() + mu2.Lock() + c2PermRequested := filterEventsByType(client2Events, copilot.PermissionRequested) + mu2.Unlock() + + if len(c1PermRequested) == 0 { + t.Errorf("Expected client 1 to see permission.requested events") + } + if len(c2PermRequested) == 0 { + t.Errorf("Expected client 2 to see permission.requested events") + } + + // Both clients should see the denial in the completed event + mu1.Lock() + c1PermCompleted := filterEventsByType(client1Events, copilot.PermissionCompleted) + mu1.Unlock() + mu2.Lock() + c2PermCompleted := filterEventsByType(client2Events, copilot.PermissionCompleted) + mu2.Unlock() + + if len(c1PermCompleted) == 0 { + t.Errorf("Expected client 1 to see permission.completed events") + } + if len(c2PermCompleted) == 0 { + t.Errorf("Expected client 2 to see permission.completed events") + } + for _, event := range append(c1PermCompleted, c2PermCompleted...) { + if event.Data.Result == nil || event.Data.Result.Kind == nil || *event.Data.Result.Kind != "denied-interactively-by-user" { + t.Errorf("Expected permission.completed result kind 'denied-interactively-by-user', got %v", event.Data.Result) + } + } + + session2.Disconnect() + }) + + t.Run("two clients register different tools and agent uses both", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type CountryCodeParams struct { + CountryCode string `json:"countryCode" jsonschema:"A two-letter country code"` + } + + toolA := copilot.DefineTool("city_lookup", "Returns a city name for a given country code", + func(params CountryCodeParams, inv copilot.ToolInvocation) (string, error) { + return fmt.Sprintf("CITY_FOR_%s", params.CountryCode), nil + }) + + toolB := copilot.DefineTool("currency_lookup", "Returns a currency for a given country code", + func(params CountryCodeParams, inv copilot.ToolInvocation) (string, error) { + return fmt.Sprintf("CURRENCY_FOR_%s", params.CountryCode), nil + }) + + // Client 1 creates a session with tool A + session1, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{toolA}, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Client 2 resumes with tool B (different tool, union should have both) + session2, err := client2.ResumeSession(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{toolB}, + }) + if err != nil { + t.Fatalf("Failed to resume session: %v", err) + } + + // Send prompts sequentially to avoid nondeterministic tool_call ordering + response1, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the city_lookup tool with countryCode 'US' and tell me the result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if response1 == nil || response1.Data.Content == nil { + t.Fatalf("Expected response with content") + } + if !strings.Contains(*response1.Data.Content, "CITY_FOR_US") { + t.Errorf("Expected response to contain 'CITY_FOR_US', got '%s'", *response1.Data.Content) + } + + response2, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Now use the currency_lookup tool with countryCode 'US' and tell me the result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if response2 == nil || response2.Data.Content == nil { + t.Fatalf("Expected response with content") + } + if !strings.Contains(*response2.Data.Content, "CURRENCY_FOR_US") { + t.Errorf("Expected response to contain 'CURRENCY_FOR_US', got '%s'", *response2.Data.Content) + } + + session2.Disconnect() + }) + + t.Run("disconnecting client removes its tools", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type InputParams struct { + Input string `json:"input" jsonschema:"Input string"` + } + + toolA := copilot.DefineTool("stable_tool", "A tool that persists across disconnects", + func(params InputParams, inv copilot.ToolInvocation) (string, error) { + return fmt.Sprintf("STABLE_%s", params.Input), nil + }) + + toolB := copilot.DefineTool("ephemeral_tool", "A tool that will disappear when its client disconnects", + func(params InputParams, inv copilot.ToolInvocation) (string, error) { + return fmt.Sprintf("EPHEMERAL_%s", params.Input), nil + }) + + // Client 1 creates a session with stable_tool + session1, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{toolA}, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Client 2 resumes with ephemeral_tool + _, err = client2.ResumeSession(t.Context(), session1.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{toolB}, + }) + if err != nil { + t.Fatalf("Failed to resume session: %v", err) + } + + // Verify both tools work before disconnect (sequential to avoid nondeterministic tool_call ordering) + stableResponse, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the stable_tool with input 'test1' and tell me the result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if stableResponse == nil || stableResponse.Data.Content == nil { + t.Fatalf("Expected response with content") + } + if !strings.Contains(*stableResponse.Data.Content, "STABLE_test1") { + t.Errorf("Expected response to contain 'STABLE_test1', got '%s'", *stableResponse.Data.Content) + } + + ephemeralResponse, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the ephemeral_tool with input 'test2' and tell me the result.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if ephemeralResponse == nil || ephemeralResponse.Data.Content == nil { + t.Fatalf("Expected response with content") + } + if !strings.Contains(*ephemeralResponse.Data.Content, "EPHEMERAL_test2") { + t.Errorf("Expected response to contain 'EPHEMERAL_test2', got '%s'", *ephemeralResponse.Data.Content) + } + + // Disconnect client 2 without destroying the shared session + client2.ForceStop() + + // Give the server time to process the connection close and remove tools + time.Sleep(500 * time.Millisecond) + + // Recreate client2 for cleanup (but don't rejoin the session) + client2 = copilot.NewClient(&copilot.ClientOptions{ + CLIUrl: fmt.Sprintf("localhost:%d", actualPort), + }) + + // Now only stable_tool should be available + afterResponse, err := session1.SendAndWait(t.Context(), copilot.MessageOptions{ + Prompt: "Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available.", + }) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if afterResponse == nil || afterResponse.Data.Content == nil { + t.Fatalf("Expected response with content") + } + if !strings.Contains(*afterResponse.Data.Content, "STABLE_still_here") { + t.Errorf("Expected response to contain 'STABLE_still_here', got '%s'", *afterResponse.Data.Content) + } + // ephemeral_tool should NOT have produced a result + if strings.Contains(*afterResponse.Data.Content, "EPHEMERAL_") { + t.Errorf("Expected response NOT to contain 'EPHEMERAL_', got '%s'", *afterResponse.Data.Content) + } + }) +} + +func filterEventsByType(events []copilot.SessionEvent, eventType copilot.SessionEventType) []copilot.SessionEvent { + var filtered []copilot.SessionEvent + for _, e := range events { + if e.Type == eventType { + filtered = append(filtered, e) + } + } + return filtered +} diff --git a/go/internal/e2e/permissions_test.go b/go/internal/e2e/permissions_test.go index 1584f0244..328e7e788 100644 --- a/go/internal/e2e/permissions_test.go +++ b/go/internal/e2e/permissions_test.go @@ -31,7 +31,7 @@ func TestPermissions(t *testing.T) { t.Error("Expected non-empty session ID in invocation") } - return copilot.PermissionRequestResult{Kind: "approved"}, nil + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ @@ -82,7 +82,7 @@ func TestPermissions(t *testing.T) { permissionRequests = append(permissionRequests, request) mu.Unlock() - return copilot.PermissionRequestResult{Kind: "approved"}, nil + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ @@ -117,7 +117,7 @@ func TestPermissions(t *testing.T) { ctx.ConfigureForTest(t) onPermissionRequest := func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { - return copilot.PermissionRequestResult{Kind: "denied-interactively-by-user"}, nil + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindDeniedInteractivelyByUser}, nil } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ @@ -157,10 +157,14 @@ func TestPermissions(t *testing.T) { } }) - t.Run("should deny tool operations by default when no handler is provided", func(t *testing.T) { + t.Run("should deny tool operations when handler explicitly denies", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindDeniedCouldNotRequestFromUser}, nil + }, + }) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -192,7 +196,7 @@ func TestPermissions(t *testing.T) { } }) - t.Run("should deny tool operations by default when no handler is provided after resume", func(t *testing.T) { + t.Run("should deny tool operations when handler explicitly denies after resume", func(t *testing.T) { ctx.ConfigureForTest(t) session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ @@ -206,7 +210,11 @@ func TestPermissions(t *testing.T) { t.Fatalf("Failed to send message: %v", err) } - session2, err := client.ResumeSession(t.Context(), sessionID) + session2, err := client.ResumeSession(t.Context(), sessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindDeniedCouldNotRequestFromUser}, nil + }, + }) if err != nil { t.Fatalf("Failed to resume session: %v", err) } @@ -238,10 +246,12 @@ func TestPermissions(t *testing.T) { } }) - t.Run("without permission handler", func(t *testing.T) { + t.Run("should work with approve-all permission handler", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to create session: %v", err) } diff --git a/go/internal/e2e/rpc_test.go b/go/internal/e2e/rpc_test.go index 43b7cafa8..61a5e338d 100644 --- a/go/internal/e2e/rpc_test.go +++ b/go/internal/e2e/rpc_test.go @@ -130,7 +130,8 @@ func TestSessionRpc(t *testing.T) { t.Skip("session.model.getCurrent not yet implemented in CLI") session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - Model: "claude-sonnet-4.5", + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Model: "claude-sonnet-4.5", }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -151,7 +152,8 @@ func TestSessionRpc(t *testing.T) { t.Skip("session.model.switchTo not yet implemented in CLI") session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - Model: "claude-sonnet-4.5", + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Model: "claude-sonnet-4.5", }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -187,8 +189,25 @@ func TestSessionRpc(t *testing.T) { } }) + // session.model.switchTo is defined in schema but not yet implemented in CLI + t.Run("should call session.SetModel", func(t *testing.T) { + t.Skip("session.model.switchTo not yet implemented in CLI") + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Model: "claude-sonnet-4.5", + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + if err := session.SetModel(t.Context(), "gpt-4.1"); err != nil { + t.Fatalf("SetModel returned error: %v", err) + } + }) + t.Run("should get and set session mode", func(t *testing.T) { - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -231,7 +250,7 @@ func TestSessionRpc(t *testing.T) { }) t.Run("should read, update, and delete plan", func(t *testing.T) { - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -287,7 +306,7 @@ func TestSessionRpc(t *testing.T) { }) t.Run("should create, list, and read workspace files", func(t *testing.T) { - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index 87341838a..d1902311f 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -15,10 +15,10 @@ func TestSession(t *testing.T) { client := ctx.NewClient() t.Cleanup(func() { client.ForceStop() }) - t.Run("should create and destroy sessions", func(t *testing.T) { + t.Run("should create and disconnect sessions", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{Model: "fake-test-model"}) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Model: "fake-test-model"}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -45,20 +45,20 @@ func TestSession(t *testing.T) { t.Errorf("Expected selectedModel to be 'fake-test-model', got %v", messages[0].Data.SelectedModel) } - if err := session.Destroy(); err != nil { - t.Fatalf("Failed to destroy session: %v", err) + if err := session.Disconnect(); err != nil { + t.Fatalf("Failed to disconnect session: %v", err) } _, err = session.GetMessages(t.Context()) if err == nil || !strings.Contains(err.Error(), "not found") { - t.Errorf("Expected GetMessages to fail with 'not found' after destroy, got %v", err) + t.Errorf("Expected GetMessages to fail with 'not found' after disconnect, got %v", err) } }) t.Run("should have stateful conversation", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -87,6 +87,7 @@ func TestSession(t *testing.T) { systemMessageSuffix := "End each response with the phrase 'Have a nice day!'" session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, SystemMessage: &copilot.SystemMessageConfig{ Mode: "append", Content: systemMessageSuffix, @@ -135,6 +136,7 @@ func TestSession(t *testing.T) { testSystemMessage := "You are an assistant called Testy McTestface. Reply succinctly." session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, SystemMessage: &copilot.SystemMessageConfig{ Mode: "replace", Content: testSystemMessage, @@ -184,7 +186,8 @@ func TestSession(t *testing.T) { ctx.ConfigureForTest(t) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - AvailableTools: []string{"view", "edit"}, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + AvailableTools: []string{"view", "edit"}, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -222,7 +225,8 @@ func TestSession(t *testing.T) { ctx.ConfigureForTest(t) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - ExcludedTools: []string{"view"}, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + ExcludedTools: []string{"view"}, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -260,6 +264,7 @@ func TestSession(t *testing.T) { ctx.ConfigureForTest(t) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Tools: []copilot.Tool{ { Name: "get_secret_number", @@ -323,7 +328,7 @@ func TestSession(t *testing.T) { ctx.ConfigureForTest(t) // Create initial session - session1, err := client.CreateSession(t.Context(), nil) + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -344,7 +349,9 @@ func TestSession(t *testing.T) { } // Resume using the same client - session2, err := client.ResumeSession(t.Context(), sessionID) + session2, err := client.ResumeSession(t.Context(), sessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to resume session: %v", err) } @@ -361,13 +368,22 @@ func TestSession(t *testing.T) { if answer2.Data.Content == nil || !strings.Contains(*answer2.Data.Content, "2") { t.Errorf("Expected resumed session answer to contain '2', got %v", answer2.Data.Content) } + + // Can continue the conversation statefully + answer3, err := session2.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Now if you double that, what do you get?"}) + if err != nil { + t.Fatalf("Failed to send follow-up message: %v", err) + } + if answer3 == nil || answer3.Data.Content == nil || !strings.Contains(*answer3.Data.Content, "4") { + t.Errorf("Expected follow-up answer to contain '4', got %v", answer3) + } }) t.Run("should resume a session using a new client", func(t *testing.T) { ctx.ConfigureForTest(t) // Create initial session - session1, err := client.CreateSession(t.Context(), nil) + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -391,7 +407,9 @@ func TestSession(t *testing.T) { newClient := ctx.NewClient() defer newClient.ForceStop() - session2, err := newClient.ResumeSession(t.Context(), sessionID) + session2, err := newClient.ResumeSession(t.Context(), sessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { t.Fatalf("Failed to resume session: %v", err) } @@ -423,12 +441,23 @@ func TestSession(t *testing.T) { if !hasSessionResume { t.Error("Expected messages to contain 'session.resume'") } + + // Can continue the conversation statefully + answer3, err := session2.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Now if you double that, what do you get?"}) + if err != nil { + t.Fatalf("Failed to send follow-up message: %v", err) + } + if answer3 == nil || answer3.Data.Content == nil || !strings.Contains(*answer3.Data.Content, "4") { + t.Errorf("Expected follow-up answer to contain '4', got %v", answer3) + } }) t.Run("should throw error when resuming non-existent session", func(t *testing.T) { ctx.ConfigureForTest(t) - _, err := client.ResumeSession(t.Context(), "non-existent-session-id") + _, err := client.ResumeSession(t.Context(), "non-existent-session-id", &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err == nil { t.Error("Expected error when resuming non-existent session") } @@ -437,7 +466,7 @@ func TestSession(t *testing.T) { t.Run("should resume session with a custom provider", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -445,6 +474,7 @@ func TestSession(t *testing.T) { // Resume the session with a provider session2, err := client.ResumeSessionWithOptions(t.Context(), sessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Provider: &copilot.ProviderConfig{ Type: "openai", BaseURL: "https://api.openai.com/v1", @@ -553,101 +583,10 @@ func TestSession(t *testing.T) { } }) - t.Run("should receive streaming delta events when streaming is enabled", func(t *testing.T) { - ctx.ConfigureForTest(t) - - session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - Streaming: true, - }) - if err != nil { - t.Fatalf("Failed to create session with streaming: %v", err) - } - - var deltaContents []string - done := make(chan bool) - - session.On(func(event copilot.SessionEvent) { - switch event.Type { - case "assistant.message_delta": - if event.Data.DeltaContent != nil { - deltaContents = append(deltaContents, *event.Data.DeltaContent) - } - case "session.idle": - close(done) - } - }) - - _, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "What is 2+2?"}) - if err != nil { - t.Fatalf("Failed to send message: %v", err) - } - - // Wait for completion - select { - case <-done: - case <-time.After(60 * time.Second): - t.Fatal("Timed out waiting for session.idle") - } - - // Should have received delta events - if len(deltaContents) == 0 { - t.Error("Expected to receive delta events, got none") - } - - // Get the final message to compare - assistantMessage, err := testharness.GetFinalAssistantMessage(t.Context(), session) - if err != nil { - t.Fatalf("Failed to get assistant message: %v", err) - } - - // Accumulated deltas should equal the final message - accumulated := strings.Join(deltaContents, "") - if assistantMessage.Data.Content != nil && accumulated != *assistantMessage.Data.Content { - t.Errorf("Accumulated deltas don't match final message.\nAccumulated: %q\nFinal: %q", accumulated, *assistantMessage.Data.Content) - } - - // Final message should contain the answer - if assistantMessage.Data.Content == nil || !strings.Contains(*assistantMessage.Data.Content, "4") { - t.Errorf("Expected assistant message to contain '4', got %v", assistantMessage.Data.Content) - } - }) - - t.Run("should pass streaming option to session creation", func(t *testing.T) { - ctx.ConfigureForTest(t) - - // Verify that the streaming option is accepted without errors - session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - Streaming: true, - }) - if err != nil { - t.Fatalf("Failed to create session with streaming: %v", err) - } - - matched, _ := regexp.MatchString(`^[a-f0-9-]+$`, session.SessionID) - if !matched { - t.Errorf("Expected session ID to match UUID pattern, got %q", session.SessionID) - } - - // Session should still work normally - _, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "What is 1+1?"}) - if err != nil { - t.Fatalf("Failed to send message: %v", err) - } - - assistantMessage, err := testharness.GetFinalAssistantMessage(t.Context(), session) - if err != nil { - t.Fatalf("Failed to get assistant message: %v", err) - } - - if assistantMessage.Data.Content == nil || !strings.Contains(*assistantMessage.Data.Content, "2") { - t.Errorf("Expected assistant message to contain '2', got %v", assistantMessage.Data.Content) - } - }) - t.Run("should receive session events", func(t *testing.T) { ctx.ConfigureForTest(t) - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -722,7 +661,8 @@ func TestSession(t *testing.T) { customConfigDir := ctx.HomeDir + "/custom-config" session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - ConfigDir: customConfigDir, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + ConfigDir: customConfigDir, }) if err != nil { t.Fatalf("Failed to create session with custom config dir: %v", err) @@ -753,7 +693,7 @@ func TestSession(t *testing.T) { ctx.ConfigureForTest(t) // Create a couple of sessions and send messages to persist them - session1, err := client.CreateSession(t.Context(), nil) + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session1: %v", err) } @@ -763,7 +703,7 @@ func TestSession(t *testing.T) { t.Fatalf("Failed to send message to session1: %v", err) } - session2, err := client.CreateSession(t.Context(), nil) + session2, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session2: %v", err) } @@ -829,7 +769,7 @@ func TestSession(t *testing.T) { ctx.ConfigureForTest(t) // Create a session and send a message to persist it - session, err := client.CreateSession(t.Context(), nil) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -881,11 +821,47 @@ func TestSession(t *testing.T) { } // Verify we cannot resume the deleted session - _, err = client.ResumeSession(t.Context(), sessionID) + _, err = client.ResumeSession(t.Context(), sessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err == nil { t.Error("Expected error when resuming deleted session") } }) + t.Run("should get last session id", func(t *testing.T) { + ctx.ConfigureForTest(t) + + // Create a session and send a message to persist it + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Say hello"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // Small delay to ensure session data is flushed to disk + time.Sleep(500 * time.Millisecond) + + lastSessionID, err := client.GetLastSessionID(t.Context()) + if err != nil { + t.Fatalf("Failed to get last session ID: %v", err) + } + + if lastSessionID == nil { + t.Fatal("Expected last session ID to be non-nil") + } + + if *lastSessionID != session.SessionID { + t.Errorf("Expected last session ID to be %s, got %s", session.SessionID, *lastSessionID) + } + + if err := session.Disconnect(); err != nil { + t.Fatalf("Failed to destroy session: %v", err) + } + }) } func getSystemMessage(exchange testharness.ParsedHttpExchange) string { diff --git a/go/internal/e2e/skills_test.go b/go/internal/e2e/skills_test.go index ed3578abd..524280fd8 100644 --- a/go/internal/e2e/skills_test.go +++ b/go/internal/e2e/skills_test.go @@ -57,7 +57,8 @@ func TestSkills(t *testing.T) { skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - SkillDirectories: []string{skillsDir}, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SkillDirectories: []string{skillsDir}, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -75,7 +76,7 @@ func TestSkills(t *testing.T) { t.Errorf("Expected message to contain skill marker '%s', got: %v", skillMarker, message.Data.Content) } - session.Destroy() + session.Disconnect() }) t.Run("should not apply skill when disabled via disabledSkills", func(t *testing.T) { @@ -84,8 +85,9 @@ func TestSkills(t *testing.T) { skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ - SkillDirectories: []string{skillsDir}, - DisabledSkills: []string{"test-skill"}, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SkillDirectories: []string{skillsDir}, + DisabledSkills: []string{"test-skill"}, }) if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -103,7 +105,7 @@ func TestSkills(t *testing.T) { t.Errorf("Expected message to NOT contain skill marker '%s' when disabled, got: %v", skillMarker, *message.Data.Content) } - session.Destroy() + session.Disconnect() }) t.Run("should apply skill on session resume with skillDirectories", func(t *testing.T) { @@ -113,7 +115,7 @@ func TestSkills(t *testing.T) { skillsDir := createTestSkillDir(t, ctx.WorkDir, skillMarker) // Create a session without skills first - session1, err := client.CreateSession(t.Context(), nil) + session1, err := client.CreateSession(t.Context(), &copilot.SessionConfig{OnPermissionRequest: copilot.PermissionHandler.ApproveAll}) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -131,7 +133,8 @@ func TestSkills(t *testing.T) { // Resume with skillDirectories - skill should now be active session2, err := client.ResumeSessionWithOptions(t.Context(), sessionID, &copilot.ResumeSessionConfig{ - SkillDirectories: []string{skillsDir}, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SkillDirectories: []string{skillsDir}, }) if err != nil { t.Fatalf("Failed to resume session: %v", err) @@ -151,6 +154,6 @@ func TestSkills(t *testing.T) { t.Errorf("Expected message to contain skill marker '%s' after resume, got: %v", skillMarker, message2.Data.Content) } - session2.Destroy() + session2.Disconnect() }) } diff --git a/go/internal/e2e/streaming_fidelity_test.go b/go/internal/e2e/streaming_fidelity_test.go new file mode 100644 index 000000000..ef76c3d8b --- /dev/null +++ b/go/internal/e2e/streaming_fidelity_test.go @@ -0,0 +1,186 @@ +package e2e + +import ( + "strings" + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" +) + +func TestStreamingFidelity(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should produce delta events when streaming is enabled", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Streaming: true, + }) + if err != nil { + t.Fatalf("Failed to create session with streaming: %v", err) + } + + var events []copilot.SessionEvent + session.On(func(event copilot.SessionEvent) { + events = append(events, event) + }) + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Count from 1 to 5, separated by commas."}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // Should have streaming deltas before the final message + var deltaEvents []copilot.SessionEvent + for _, e := range events { + if e.Type == "assistant.message_delta" { + deltaEvents = append(deltaEvents, e) + } + } + if len(deltaEvents) < 1 { + t.Error("Expected at least 1 delta event") + } + + // Deltas should have content + for _, delta := range deltaEvents { + if delta.Data.DeltaContent == nil { + t.Error("Expected delta to have content") + } + } + + // Should still have a final assistant.message + hasAssistantMessage := false + for _, e := range events { + if e.Type == "assistant.message" { + hasAssistantMessage = true + break + } + } + if !hasAssistantMessage { + t.Error("Expected a final assistant.message event") + } + + // Deltas should come before the final message + firstDeltaIdx := -1 + lastAssistantIdx := -1 + for i, e := range events { + if e.Type == "assistant.message_delta" && firstDeltaIdx == -1 { + firstDeltaIdx = i + } + if e.Type == "assistant.message" { + lastAssistantIdx = i + } + } + if firstDeltaIdx >= lastAssistantIdx { + t.Errorf("Expected deltas before final message, got delta at %d, message at %d", firstDeltaIdx, lastAssistantIdx) + } + }) + + t.Run("should not produce deltas when streaming is disabled", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Streaming: false, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + var events []copilot.SessionEvent + session.On(func(event copilot.SessionEvent) { + events = append(events, event) + }) + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Say 'hello world'."}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // No deltas when streaming is off + var deltaEvents []copilot.SessionEvent + for _, e := range events { + if e.Type == "assistant.message_delta" { + deltaEvents = append(deltaEvents, e) + } + } + if len(deltaEvents) != 0 { + t.Errorf("Expected no delta events, got %d", len(deltaEvents)) + } + + // But should still have a final assistant.message + var assistantEvents []copilot.SessionEvent + for _, e := range events { + if e.Type == "assistant.message" { + assistantEvents = append(assistantEvents, e) + } + } + if len(assistantEvents) < 1 { + t.Error("Expected at least 1 assistant.message event") + } + }) + + t.Run("should produce deltas after session resume", func(t *testing.T) { + ctx.ConfigureForTest(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Streaming: false, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "What is 3 + 6?"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + // Resume using a new client + newClient := ctx.NewClient() + defer newClient.ForceStop() + + session2, err := newClient.ResumeSession(t.Context(), session.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Streaming: true, + }) + if err != nil { + t.Fatalf("Failed to resume session: %v", err) + } + + var events []copilot.SessionEvent + session2.On(func(event copilot.SessionEvent) { + events = append(events, event) + }) + + answer, err := session2.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Now if you double that, what do you get?"}) + if err != nil { + t.Fatalf("Failed to send follow-up message: %v", err) + } + if answer == nil || answer.Data.Content == nil || !strings.Contains(*answer.Data.Content, "18") { + t.Errorf("Expected answer to contain '18', got %v", answer) + } + + // Should have streaming deltas before the final message + var deltaEvents []copilot.SessionEvent + for _, e := range events { + if e.Type == "assistant.message_delta" { + deltaEvents = append(deltaEvents, e) + } + } + if len(deltaEvents) < 1 { + t.Error("Expected at least 1 delta event") + } + + // Deltas should have content + for _, delta := range deltaEvents { + if delta.Data.DeltaContent == nil { + t.Error("Expected delta to have content") + } + } + }) +} diff --git a/go/internal/e2e/testharness/context.go b/go/internal/e2e/testharness/context.go index 570594edc..b9edab1e5 100644 --- a/go/internal/e2e/testharness/context.go +++ b/go/internal/e2e/testharness/context.go @@ -166,8 +166,8 @@ func (c *TestContext) NewClient() *copilot.Client { } // Use fake token in CI to allow cached responses without real auth - if os.Getenv("CI") == "true" { - options.GithubToken = "fake-token-for-e2e-tests" + if os.Getenv("GITHUB_ACTIONS") == "true" { + options.GitHubToken = "fake-token-for-e2e-tests" } return copilot.NewClient(options) diff --git a/go/internal/e2e/tools_test.go b/go/internal/e2e/tools_test.go index d54bdcb14..83f3780c1 100644 --- a/go/internal/e2e/tools_test.go +++ b/go/internal/e2e/tools_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" copilot "github.com/github/copilot-sdk/go" @@ -55,6 +56,7 @@ func TestTools(t *testing.T) { } session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Tools: []copilot.Tool{ copilot.DefineTool("encrypt_string", "Encrypts a string", func(params EncryptParams, inv copilot.ToolInvocation) (string, error) { @@ -87,6 +89,7 @@ func TestTools(t *testing.T) { type EmptyParams struct{} session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Tools: []copilot.Tool{ copilot.DefineTool("get_user_location", "Gets the user's location", func(params EmptyParams, inv copilot.ToolInvocation) (any, error) { @@ -189,6 +192,7 @@ func TestTools(t *testing.T) { var receivedInvocation *copilot.ToolInvocation session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Tools: []copilot.Tool{ copilot.DefineTool("db_query", "Performs a database query", func(params DbQueryParams, inv copilot.ToolInvocation) ([]City, error) { @@ -259,4 +263,141 @@ func TestTools(t *testing.T) { t.Errorf("Expected session ID '%s', got '%s'", session.SessionID, receivedInvocation.SessionID) } }) + + t.Run("overrides built-in tool with custom tool", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type GrepParams struct { + Query string `json:"query" jsonschema:"Search query"` + } + + grepTool := copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in", + func(params GrepParams, inv copilot.ToolInvocation) (string, error) { + return "CUSTOM_GREP_RESULT: " + params.Query, nil + }) + grepTool.OverridesBuiltInTool = true + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{ + grepTool, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "Use grep to search for the word 'hello'"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + answer, err := testharness.GetFinalAssistantMessage(t.Context(), session) + if err != nil { + t.Fatalf("Failed to get assistant message: %v", err) + } + + if answer.Data.Content == nil || !strings.Contains(*answer.Data.Content, "CUSTOM_GREP_RESULT") { + t.Errorf("Expected answer to contain 'CUSTOM_GREP_RESULT', got %v", answer.Data.Content) + } + }) + + t.Run("invokes custom tool with permission handler", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type EncryptParams struct { + Input string `json:"input" jsonschema:"String to encrypt"` + } + + var permissionRequests []copilot.PermissionRequest + var mu sync.Mutex + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Tools: []copilot.Tool{ + copilot.DefineTool("encrypt_string", "Encrypts a string", + func(params EncryptParams, inv copilot.ToolInvocation) (string, error) { + return strings.ToUpper(params.Input), nil + }), + }, + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + mu.Lock() + permissionRequests = append(permissionRequests, request) + mu.Unlock() + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindApproved}, nil + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "Use encrypt_string to encrypt this string: Hello"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + answer, err := testharness.GetFinalAssistantMessage(t.Context(), session) + if err != nil { + t.Fatalf("Failed to get assistant message: %v", err) + } + + if answer.Data.Content == nil || !strings.Contains(*answer.Data.Content, "HELLO") { + t.Errorf("Expected answer to contain 'HELLO', got %v", answer.Data.Content) + } + + // Should have received a custom-tool permission request + mu.Lock() + customToolReqs := 0 + for _, req := range permissionRequests { + if req.Kind == "custom-tool" { + customToolReqs++ + if req.ToolName == nil || *req.ToolName != "encrypt_string" { + t.Errorf("Expected toolName 'encrypt_string', got '%v'", req.ToolName) + } + } + } + mu.Unlock() + if customToolReqs == 0 { + t.Errorf("Expected at least one custom-tool permission request, got none") + } + }) + + t.Run("denies custom tool when permission denied", func(t *testing.T) { + ctx.ConfigureForTest(t) + + type EncryptParams struct { + Input string `json:"input" jsonschema:"String to encrypt"` + } + + toolHandlerCalled := false + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Tools: []copilot.Tool{ + copilot.DefineTool("encrypt_string", "Encrypts a string", + func(params EncryptParams, inv copilot.ToolInvocation) (string, error) { + toolHandlerCalled = true + return strings.ToUpper(params.Input), nil + }), + }, + OnPermissionRequest: func(request copilot.PermissionRequest, invocation copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { + return copilot.PermissionRequestResult{Kind: copilot.PermissionRequestResultKindDeniedInteractivelyByUser}, nil + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.Send(t.Context(), copilot.MessageOptions{Prompt: "Use encrypt_string to encrypt this string: Hello"}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + _, err = testharness.GetFinalAssistantMessage(t.Context(), session) + if err != nil { + t.Fatalf("Failed to get assistant message: %v", err) + } + + if toolHandlerCalled { + t.Errorf("Tool handler should NOT have been called since permission was denied") + } + }) } diff --git a/go/internal/jsonrpc2/jsonrpc2.go b/go/internal/jsonrpc2/jsonrpc2.go index 03cf49b3c..09505c06d 100644 --- a/go/internal/jsonrpc2/jsonrpc2.go +++ b/go/internal/jsonrpc2/jsonrpc2.go @@ -8,6 +8,7 @@ import ( "io" "reflect" "sync" + "sync/atomic" ) // Error represents a JSON-RPC error response @@ -54,7 +55,7 @@ type Client struct { mu sync.Mutex pendingRequests map[string]chan *Response requestHandlers map[string]RequestHandler - running bool + running atomic.Bool stopChan chan struct{} wg sync.WaitGroup processDone chan struct{} // closed when the underlying process exits @@ -97,17 +98,17 @@ func (c *Client) getProcessError() error { // Start begins listening for messages in a background goroutine func (c *Client) Start() { - c.running = true + c.running.Store(true) c.wg.Add(1) go c.readLoop() } // Stop stops the client and cleans up func (c *Client) Stop() { - if !c.running { + if !c.running.Load() { return } - c.running = false + c.running.Store(false) close(c.stopChan) // Close stdout to unblock the readLoop @@ -298,14 +299,14 @@ func (c *Client) readLoop() { reader := bufio.NewReader(c.stdout) - for c.running { + for c.running.Load() { // Read Content-Length header var contentLength int for { line, err := reader.ReadString('\n') if err != nil { // Only log unexpected errors (not EOF or closed pipe during shutdown) - if err != io.EOF && c.running { + if err != io.EOF && c.running.Load() { fmt.Printf("Error reading header: %v\n", err) } return diff --git a/go/permissions.go b/go/permissions.go index 91ff776cf..fb28851e3 100644 --- a/go/permissions.go +++ b/go/permissions.go @@ -6,6 +6,6 @@ var PermissionHandler = struct { ApproveAll PermissionHandlerFunc }{ ApproveAll: func(_ PermissionRequest, _ PermissionInvocation) (PermissionRequestResult, error) { - return PermissionRequestResult{Kind: "approved"}, nil + return PermissionRequestResult{Kind: PermissionRequestResultKindApproved}, nil }, } diff --git a/go/rpc/generated_rpc.go b/go/rpc/generated_rpc.go index c7d9b0c07..67a354202 100644 --- a/go/rpc/generated_rpc.go +++ b/go/rpc/generated_rpc.go @@ -65,8 +65,8 @@ type Limits struct { type Supports struct { // Whether this model supports reasoning effort configuration - ReasoningEffort bool `json:"reasoningEffort"` - Vision bool `json:"vision"` + ReasoningEffort *bool `json:"reasoningEffort,omitempty"` + Vision *bool `json:"vision,omitempty"` } // Policy state (if applicable) @@ -148,17 +148,19 @@ type SessionModeSetParams struct { } type SessionPlanReadResult struct { - // The content of plan.md, or null if it does not exist + // The content of the plan file, or null if it does not exist Content *string `json:"content"` - // Whether plan.md exists in the workspace + // Whether the plan file exists in the workspace Exists bool `json:"exists"` + // Absolute file path of the plan file, or null if workspace is not enabled + Path *string `json:"path"` } type SessionPlanUpdateResult struct { } type SessionPlanUpdateParams struct { - // The new content for plan.md + // The new content for the plan file Content string `json:"content"` } @@ -200,6 +202,100 @@ type SessionFleetStartParams struct { Prompt *string `json:"prompt,omitempty"` } +type SessionAgentListResult struct { + // Available custom agents + Agents []AgentElement `json:"agents"` +} + +type AgentElement struct { + // Description of the agent's purpose + Description string `json:"description"` + // Human-readable display name + DisplayName string `json:"displayName"` + // Unique identifier of the custom agent + Name string `json:"name"` +} + +type SessionAgentGetCurrentResult struct { + // Currently selected custom agent, or null if using the default agent + Agent *SessionAgentGetCurrentResultAgent `json:"agent"` +} + +type SessionAgentGetCurrentResultAgent struct { + // Description of the agent's purpose + Description string `json:"description"` + // Human-readable display name + DisplayName string `json:"displayName"` + // Unique identifier of the custom agent + Name string `json:"name"` +} + +type SessionAgentSelectResult struct { + // The newly selected custom agent + Agent SessionAgentSelectResultAgent `json:"agent"` +} + +// The newly selected custom agent +type SessionAgentSelectResultAgent struct { + // Description of the agent's purpose + Description string `json:"description"` + // Human-readable display name + DisplayName string `json:"displayName"` + // Unique identifier of the custom agent + Name string `json:"name"` +} + +type SessionAgentSelectParams struct { + // Name of the custom agent to select + Name string `json:"name"` +} + +type SessionAgentDeselectResult struct { +} + +type SessionCompactionCompactResult struct { + // Number of messages removed during compaction + MessagesRemoved float64 `json:"messagesRemoved"` + // Whether compaction completed successfully + Success bool `json:"success"` + // Number of tokens freed by compaction + TokensRemoved float64 `json:"tokensRemoved"` +} + +type SessionToolsHandlePendingToolCallResult struct { + Success bool `json:"success"` +} + +type SessionToolsHandlePendingToolCallParams struct { + Error *string `json:"error,omitempty"` + RequestID string `json:"requestId"` + Result *ResultUnion `json:"result"` +} + +type ResultResult struct { + Error *string `json:"error,omitempty"` + ResultType *string `json:"resultType,omitempty"` + TextResultForLlm string `json:"textResultForLlm"` + ToolTelemetry map[string]interface{} `json:"toolTelemetry,omitempty"` +} + +type SessionPermissionsHandlePendingPermissionRequestResult struct { + Success bool `json:"success"` +} + +type SessionPermissionsHandlePendingPermissionRequestParams struct { + RequestID string `json:"requestId"` + Result SessionPermissionsHandlePendingPermissionRequestParamsResult `json:"result"` +} + +type SessionPermissionsHandlePendingPermissionRequestParamsResult struct { + Kind Kind `json:"kind"` + Rules []interface{} `json:"rules,omitempty"` + Feedback *string `json:"feedback,omitempty"` + Message *string `json:"message,omitempty"` + Path *string `json:"path,omitempty"` +} + // The current agent mode. // // The agent mode after switching. @@ -213,9 +309,24 @@ const ( Plan Mode = "plan" ) -type ModelsRpcApi struct{ client *jsonrpc2.Client } +type Kind string + +const ( + Approved Kind = "approved" + DeniedByContentExclusionPolicy Kind = "denied-by-content-exclusion-policy" + DeniedByRules Kind = "denied-by-rules" + DeniedInteractivelyByUser Kind = "denied-interactively-by-user" + DeniedNoApprovalRuleAndCouldNotRequestFromUser Kind = "denied-no-approval-rule-and-could-not-request-from-user" +) -func (a *ModelsRpcApi) List(ctx context.Context) (*ModelsListResult, error) { +type ResultUnion struct { + ResultResult *ResultResult + String *string +} + +type ServerModelsRpcApi struct{ client *jsonrpc2.Client } + +func (a *ServerModelsRpcApi) List(ctx context.Context) (*ModelsListResult, error) { raw, err := a.client.Request("models.list", map[string]interface{}{}) if err != nil { return nil, err @@ -227,9 +338,9 @@ func (a *ModelsRpcApi) List(ctx context.Context) (*ModelsListResult, error) { return &result, nil } -type ToolsRpcApi struct{ client *jsonrpc2.Client } +type ServerToolsRpcApi struct{ client *jsonrpc2.Client } -func (a *ToolsRpcApi) List(ctx context.Context, params *ToolsListParams) (*ToolsListResult, error) { +func (a *ServerToolsRpcApi) List(ctx context.Context, params *ToolsListParams) (*ToolsListResult, error) { raw, err := a.client.Request("tools.list", params) if err != nil { return nil, err @@ -241,9 +352,9 @@ func (a *ToolsRpcApi) List(ctx context.Context, params *ToolsListParams) (*Tools return &result, nil } -type AccountRpcApi struct{ client *jsonrpc2.Client } +type ServerAccountRpcApi struct{ client *jsonrpc2.Client } -func (a *AccountRpcApi) GetQuota(ctx context.Context) (*AccountGetQuotaResult, error) { +func (a *ServerAccountRpcApi) GetQuota(ctx context.Context) (*AccountGetQuotaResult, error) { raw, err := a.client.Request("account.getQuota", map[string]interface{}{}) if err != nil { return nil, err @@ -258,9 +369,9 @@ func (a *AccountRpcApi) GetQuota(ctx context.Context) (*AccountGetQuotaResult, e // ServerRpc provides typed server-scoped RPC methods. type ServerRpc struct { client *jsonrpc2.Client - Models *ModelsRpcApi - Tools *ToolsRpcApi - Account *AccountRpcApi + Models *ServerModelsRpcApi + Tools *ServerToolsRpcApi + Account *ServerAccountRpcApi } func (a *ServerRpc) Ping(ctx context.Context, params *PingParams) (*PingResult, error) { @@ -277,9 +388,9 @@ func (a *ServerRpc) Ping(ctx context.Context, params *PingParams) (*PingResult, func NewServerRpc(client *jsonrpc2.Client) *ServerRpc { return &ServerRpc{client: client, - Models: &ModelsRpcApi{client: client}, - Tools: &ToolsRpcApi{client: client}, - Account: &AccountRpcApi{client: client}, + Models: &ServerModelsRpcApi{client: client}, + Tools: &ServerToolsRpcApi{client: client}, + Account: &ServerAccountRpcApi{client: client}, } } @@ -472,23 +583,158 @@ func (a *FleetRpcApi) Start(ctx context.Context, params *SessionFleetStartParams return &result, nil } -// SessionRpc provides typed session-scoped RPC methods. -type SessionRpc struct { +type AgentRpcApi struct { client *jsonrpc2.Client sessionID string - Model *ModelRpcApi - Mode *ModeRpcApi - Plan *PlanRpcApi - Workspace *WorkspaceRpcApi - Fleet *FleetRpcApi +} + +func (a *AgentRpcApi) List(ctx context.Context) (*SessionAgentListResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.agent.list", req) + if err != nil { + return nil, err + } + var result SessionAgentListResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *AgentRpcApi) GetCurrent(ctx context.Context) (*SessionAgentGetCurrentResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.agent.getCurrent", req) + if err != nil { + return nil, err + } + var result SessionAgentGetCurrentResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *AgentRpcApi) Select(ctx context.Context, params *SessionAgentSelectParams) (*SessionAgentSelectResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["name"] = params.Name + } + raw, err := a.client.Request("session.agent.select", req) + if err != nil { + return nil, err + } + var result SessionAgentSelectResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +func (a *AgentRpcApi) Deselect(ctx context.Context) (*SessionAgentDeselectResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.agent.deselect", req) + if err != nil { + return nil, err + } + var result SessionAgentDeselectResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type CompactionRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *CompactionRpcApi) Compact(ctx context.Context) (*SessionCompactionCompactResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + raw, err := a.client.Request("session.compaction.compact", req) + if err != nil { + return nil, err + } + var result SessionCompactionCompactResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type ToolsRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *ToolsRpcApi) HandlePendingToolCall(ctx context.Context, params *SessionToolsHandlePendingToolCallParams) (*SessionToolsHandlePendingToolCallResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["requestId"] = params.RequestID + if params.Result != nil { + req["result"] = *params.Result + } + if params.Error != nil { + req["error"] = *params.Error + } + } + raw, err := a.client.Request("session.tools.handlePendingToolCall", req) + if err != nil { + return nil, err + } + var result SessionToolsHandlePendingToolCallResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +type PermissionsRpcApi struct { + client *jsonrpc2.Client + sessionID string +} + +func (a *PermissionsRpcApi) HandlePendingPermissionRequest(ctx context.Context, params *SessionPermissionsHandlePendingPermissionRequestParams) (*SessionPermissionsHandlePendingPermissionRequestResult, error) { + req := map[string]interface{}{"sessionId": a.sessionID} + if params != nil { + req["requestId"] = params.RequestID + req["result"] = params.Result + } + raw, err := a.client.Request("session.permissions.handlePendingPermissionRequest", req) + if err != nil { + return nil, err + } + var result SessionPermissionsHandlePendingPermissionRequestResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// SessionRpc provides typed session-scoped RPC methods. +type SessionRpc struct { + client *jsonrpc2.Client + sessionID string + Model *ModelRpcApi + Mode *ModeRpcApi + Plan *PlanRpcApi + Workspace *WorkspaceRpcApi + Fleet *FleetRpcApi + Agent *AgentRpcApi + Compaction *CompactionRpcApi + Tools *ToolsRpcApi + Permissions *PermissionsRpcApi } func NewSessionRpc(client *jsonrpc2.Client, sessionID string) *SessionRpc { return &SessionRpc{client: client, sessionID: sessionID, - Model: &ModelRpcApi{client: client, sessionID: sessionID}, - Mode: &ModeRpcApi{client: client, sessionID: sessionID}, - Plan: &PlanRpcApi{client: client, sessionID: sessionID}, - Workspace: &WorkspaceRpcApi{client: client, sessionID: sessionID}, - Fleet: &FleetRpcApi{client: client, sessionID: sessionID}, + Model: &ModelRpcApi{client: client, sessionID: sessionID}, + Mode: &ModeRpcApi{client: client, sessionID: sessionID}, + Plan: &PlanRpcApi{client: client, sessionID: sessionID}, + Workspace: &WorkspaceRpcApi{client: client, sessionID: sessionID}, + Fleet: &FleetRpcApi{client: client, sessionID: sessionID}, + Agent: &AgentRpcApi{client: client, sessionID: sessionID}, + Compaction: &CompactionRpcApi{client: client, sessionID: sessionID}, + Tools: &ToolsRpcApi{client: client, sessionID: sessionID}, + Permissions: &PermissionsRpcApi{client: client, sessionID: sessionID}, } } diff --git a/go/rpc/result_union.go b/go/rpc/result_union.go new file mode 100644 index 000000000..6cd948b50 --- /dev/null +++ b/go/rpc/result_union.go @@ -0,0 +1,35 @@ +package rpc + +import "encoding/json" + +// MarshalJSON serializes ResultUnion as the appropriate JSON variant: +// a plain string when String is set, or the ResultResult object otherwise. +// The generated struct has no custom marshaler, so without this the Go +// struct fields would serialize as {"ResultResult":...,"String":...} +// instead of the union the server expects. +func (r ResultUnion) MarshalJSON() ([]byte, error) { + if r.String != nil { + return json.Marshal(*r.String) + } + if r.ResultResult != nil { + return json.Marshal(*r.ResultResult) + } + return []byte("null"), nil +} + +// UnmarshalJSON deserializes a JSON value into the appropriate ResultUnion variant. +func (r *ResultUnion) UnmarshalJSON(data []byte) error { + // Try string first + var s string + if err := json.Unmarshal(data, &s); err == nil { + r.String = &s + return nil + } + // Try ResultResult object + var rr ResultResult + if err := json.Unmarshal(data, &rr); err == nil { + r.ResultResult = &rr + return nil + } + return nil +} diff --git a/go/samples/chat.go b/go/samples/chat.go index 4fc11ffda..f984f758a 100644 --- a/go/samples/chat.go +++ b/go/samples/chat.go @@ -30,7 +30,7 @@ func main() { if err != nil { panic(err) } - defer session.Destroy() + defer session.Disconnect() session.On(func(event copilot.SessionEvent) { var output string diff --git a/go/sdk_protocol_version.go b/go/sdk_protocol_version.go index 52b1ebe02..95249568b 100644 --- a/go/sdk_protocol_version.go +++ b/go/sdk_protocol_version.go @@ -4,7 +4,7 @@ package copilot // SdkProtocolVersion is the SDK protocol version. // This must match the version expected by the copilot-agent-runtime server. -const SdkProtocolVersion = 2 +const SdkProtocolVersion = 3 // GetSdkProtocolVersion returns the SDK protocol version. func GetSdkProtocolVersion() int { diff --git a/go/session.go b/go/session.go index 12d1b1afa..c06a8e1ec 100644 --- a/go/session.go +++ b/go/session.go @@ -34,7 +34,7 @@ type sessionHandler struct { // if err != nil { // log.Fatal(err) // } -// defer session.Destroy() +// defer session.Disconnect() // // // Subscribe to events // unsubscribe := session.On(func(event copilot.SessionEvent) { @@ -97,7 +97,7 @@ func newSession(sessionID string, client *jsonrpc2.Client, workspacePath string) // - options: The message options including the prompt and optional attachments. // // Returns the message ID of the response, which can be used to correlate events, -// or an error if the session has been destroyed or the connection fails. +// or an error if the session has been disconnected or the connection fails. // // Example: // @@ -303,24 +303,6 @@ func (s *Session) getPermissionHandler() PermissionHandlerFunc { return s.permissionHandler } -// handlePermissionRequest handles a permission request from the Copilot CLI. -// This is an internal method called by the SDK when the CLI requests permission. -func (s *Session) handlePermissionRequest(request PermissionRequest) (PermissionRequestResult, error) { - handler := s.getPermissionHandler() - - if handler == nil { - return PermissionRequestResult{ - Kind: "denied-no-approval-rule-and-could-not-request-from-user", - }, nil - } - - invocation := PermissionInvocation{ - SessionID: s.SessionID, - } - - return handler(request, invocation) -} - // registerUserInputHandler registers a user input handler for this session. // // When the assistant needs to ask the user a question (e.g., via ask_user tool), @@ -457,6 +439,9 @@ func (s *Session) handleHooksInvoke(hookType string, rawInput json.RawMessage) ( // This is an internal method; handlers are called synchronously and any panics // are recovered to prevent crashing the event dispatcher. func (s *Session) dispatchEvent(event SessionEvent) { + // Handle broadcast request events internally (fire-and-forget) + s.handleBroadcastEvent(event) + s.handlerMutex.RLock() handlers := make([]SessionEventHandler, 0, len(s.handlers)) for _, h := range s.handlers { @@ -477,13 +462,124 @@ func (s *Session) dispatchEvent(event SessionEvent) { } } +// handleBroadcastEvent handles broadcast request events by executing local handlers +// and responding via RPC. This implements the protocol v3 broadcast model where tool +// calls and permission requests are broadcast as session events to all clients. +func (s *Session) handleBroadcastEvent(event SessionEvent) { + switch event.Type { + case ExternalToolRequested: + requestID := event.Data.RequestID + toolName := event.Data.ToolName + if requestID == nil || toolName == nil { + return + } + handler, ok := s.getToolHandler(*toolName) + if !ok { + return + } + toolCallID := "" + if event.Data.ToolCallID != nil { + toolCallID = *event.Data.ToolCallID + } + go s.executeToolAndRespond(*requestID, *toolName, toolCallID, event.Data.Arguments, handler) + + case PermissionRequested: + requestID := event.Data.RequestID + if requestID == nil || event.Data.PermissionRequest == nil { + return + } + handler := s.getPermissionHandler() + if handler == nil { + return + } + go s.executePermissionAndRespond(*requestID, *event.Data.PermissionRequest, handler) + } +} + +// executeToolAndRespond executes a tool handler and sends the result back via RPC. +func (s *Session) executeToolAndRespond(requestID, toolName, toolCallID string, arguments any, handler ToolHandler) { + defer func() { + if r := recover(); r != nil { + errMsg := fmt.Sprintf("tool panic: %v", r) + s.RPC.Tools.HandlePendingToolCall(context.Background(), &rpc.SessionToolsHandlePendingToolCallParams{ + RequestID: requestID, + Error: &errMsg, + }) + } + }() + + invocation := ToolInvocation{ + SessionID: s.SessionID, + ToolCallID: toolCallID, + ToolName: toolName, + Arguments: arguments, + } + + result, err := handler(invocation) + if err != nil { + errMsg := err.Error() + s.RPC.Tools.HandlePendingToolCall(context.Background(), &rpc.SessionToolsHandlePendingToolCallParams{ + RequestID: requestID, + Error: &errMsg, + }) + return + } + + resultStr := result.TextResultForLLM + if resultStr == "" { + resultStr = fmt.Sprintf("%v", result) + } + s.RPC.Tools.HandlePendingToolCall(context.Background(), &rpc.SessionToolsHandlePendingToolCallParams{ + RequestID: requestID, + Result: &rpc.ResultUnion{String: &resultStr}, + }) +} + +// executePermissionAndRespond executes a permission handler and sends the result back via RPC. +func (s *Session) executePermissionAndRespond(requestID string, permissionRequest PermissionRequest, handler PermissionHandlerFunc) { + defer func() { + if r := recover(); r != nil { + s.RPC.Permissions.HandlePendingPermissionRequest(context.Background(), &rpc.SessionPermissionsHandlePendingPermissionRequestParams{ + RequestID: requestID, + Result: rpc.SessionPermissionsHandlePendingPermissionRequestParamsResult{ + Kind: rpc.DeniedNoApprovalRuleAndCouldNotRequestFromUser, + }, + }) + } + }() + + invocation := PermissionInvocation{ + SessionID: s.SessionID, + } + + result, err := handler(permissionRequest, invocation) + if err != nil { + s.RPC.Permissions.HandlePendingPermissionRequest(context.Background(), &rpc.SessionPermissionsHandlePendingPermissionRequestParams{ + RequestID: requestID, + Result: rpc.SessionPermissionsHandlePendingPermissionRequestParamsResult{ + Kind: rpc.DeniedNoApprovalRuleAndCouldNotRequestFromUser, + }, + }) + return + } + + s.RPC.Permissions.HandlePendingPermissionRequest(context.Background(), &rpc.SessionPermissionsHandlePendingPermissionRequestParams{ + RequestID: requestID, + Result: rpc.SessionPermissionsHandlePendingPermissionRequestParamsResult{ + Kind: rpc.Kind(result.Kind), + Rules: result.Rules, + Feedback: nil, + }, + }) +} + // GetMessages retrieves all events and messages from this session's history. // // This returns the complete conversation history including user messages, // assistant responses, tool executions, and other session events in // chronological order. // -// Returns an error if the session has been destroyed or the connection fails. +// Returns an error if the session has been disconnected or the connection fails. // // Example: // @@ -511,24 +607,28 @@ func (s *Session) GetMessages(ctx context.Context) ([]SessionEvent, error) { return response.Events, nil } -// Destroy destroys this session and releases all associated resources. +// Disconnect closes this session and releases all in-memory resources (event +// handlers, tool handlers, permission handlers). +// +// Session state on disk (conversation history, planning state, artifacts) is +// preserved, so the conversation can be resumed later by calling +// [Client.ResumeSession] with the session ID. To permanently remove all +// session data including files on disk, use [Client.DeleteSession] instead. // -// After calling this method, the session can no longer be used. All event -// handlers and tool handlers are cleared. To continue the conversation, -// use [Client.ResumeSession] with the session ID. +// After calling this method, the session object can no longer be used. // // Returns an error if the connection fails. // // Example: // -// // Clean up when done -// if err := session.Destroy(); err != nil { -// log.Printf("Failed to destroy session: %v", err) +// // Clean up when done — session can still be resumed later +// if err := session.Disconnect(); err != nil { +// log.Printf("Failed to disconnect session: %v", err) // } -func (s *Session) Destroy() error { +func (s *Session) Disconnect() error { _, err := s.client.Request("session.destroy", sessionDestroyRequest{SessionID: s.SessionID}) if err != nil { - return fmt.Errorf("failed to destroy session: %w", err) + return fmt.Errorf("failed to disconnect session: %w", err) } // Clear handlers @@ -547,12 +647,20 @@ func (s *Session) Destroy() error { return nil } +// Deprecated: Use [Session.Disconnect] instead. Destroy will be removed in a future release. +// +// Destroy closes this session and releases all in-memory resources. +// Session data on disk is preserved for later resumption. +func (s *Session) Destroy() error { + return s.Disconnect() +} + // Abort aborts the currently processing message in this session. // // Use this to cancel a long-running request. The session remains valid // and can continue to be used for new messages. // -// Returns an error if the session has been destroyed or the connection fails. +// Returns an error if the session has been disconnected or the connection fails. // // Example: // @@ -576,3 +684,20 @@ func (s *Session) Abort(ctx context.Context) error { return nil } + +// SetModel changes the model for this session. +// The new model takes effect for the next message. Conversation history is preserved. +// +// Example: +// +// if err := session.SetModel(context.Background(), "gpt-4.1"); err != nil { +// log.Printf("Failed to set model: %v", err) +// } +func (s *Session) SetModel(ctx context.Context, model string) error { + _, err := s.RPC.Model.SwitchTo(ctx, &rpc.SessionModelSwitchToParams{ModelID: model}) + if err != nil { + return fmt.Errorf("failed to set model: %w", err) + } + + return nil +} diff --git a/go/test.sh b/go/test.sh old mode 100644 new mode 100755 index c3f33fb0b..e1dd8aaac --- a/go/test.sh +++ b/go/test.sh @@ -8,7 +8,7 @@ echo # Check prerequisites if ! command -v go &> /dev/null; then - echo "❌ Go is not installed. Please install Go 1.21 or later." + echo "❌ Go is not installed. Please install Go 1.24 or later." echo " Visit: https://golang.org/dl/" exit 1 fi @@ -43,7 +43,7 @@ cd "$(dirname "$0")" echo "=== Running Go SDK E2E Tests ===" echo -go test -v ./... +go test -v ./... -race echo echo "✅ All tests passed!" diff --git a/go/types.go b/go/types.go index 6abbf4a12..d749de74a 100644 --- a/go/types.go +++ b/go/types.go @@ -44,14 +44,14 @@ type ClientOptions struct { // If Env contains duplicate environment keys, only the last value in the // slice for each duplicate key is used. Env []string - // GithubToken is the GitHub token to use for authentication. + // GitHubToken is the GitHub token to use for authentication. // When provided, the token is passed to the CLI server via environment variable. // This takes priority over other authentication methods. - GithubToken string + GitHubToken string // UseLoggedInUser controls whether to use the logged-in user for authentication. // When true, the CLI server will attempt to use stored OAuth tokens or gh CLI auth. - // When false, only explicit tokens (GithubToken or environment variables) are used. - // Default: true (but defaults to false when GithubToken is provided). + // When false, only explicit tokens (GitHubToken or environment variables) are used. + // Default: true (but defaults to false when GitHubToken is provided). // Use Bool(false) to explicitly disable. UseLoggedInUser *bool } @@ -99,17 +99,28 @@ type SystemMessageConfig struct { Content string `json:"content,omitempty"` } -// PermissionRequest represents a permission request from the server -type PermissionRequest struct { - Kind string `json:"kind"` - ToolCallID string `json:"toolCallId,omitempty"` - Extra map[string]any `json:"-"` // Additional fields vary by kind -} +// PermissionRequestResultKind represents the kind of a permission request result. +type PermissionRequestResultKind string + +const ( + // PermissionRequestResultKindApproved indicates the permission was approved. + PermissionRequestResultKindApproved PermissionRequestResultKind = "approved" + + // PermissionRequestResultKindDeniedByRules indicates the permission was denied by rules. + PermissionRequestResultKindDeniedByRules PermissionRequestResultKind = "denied-by-rules" + + // PermissionRequestResultKindDeniedCouldNotRequestFromUser indicates the permission was denied because + // no approval rule was found and the user could not be prompted. + PermissionRequestResultKindDeniedCouldNotRequestFromUser PermissionRequestResultKind = "denied-no-approval-rule-and-could-not-request-from-user" + + // PermissionRequestResultKindDeniedInteractivelyByUser indicates the permission was denied interactively by the user. + PermissionRequestResultKindDeniedInteractivelyByUser PermissionRequestResultKind = "denied-interactively-by-user" +) // PermissionRequestResult represents the result of a permission request type PermissionRequestResult struct { - Kind string `json:"kind"` - Rules []any `json:"rules,omitempty"` + Kind PermissionRequestResultKind `json:"kind"` + Rules []any `json:"rules,omitempty"` } // PermissionHandlerFunc executes a permission request @@ -384,10 +395,11 @@ type SessionConfig struct { // Tool describes a caller-implemented tool that can be invoked by Copilot type Tool struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Parameters map[string]any `json:"parameters,omitempty"` - Handler ToolHandler `json:"-"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` + OverridesBuiltInTool bool `json:"overridesBuiltInTool,omitempty"` + Handler ToolHandler `json:"-"` } // ToolInvocation describes a tool call initiated by Copilot @@ -621,17 +633,6 @@ type SessionLifecycleEventMetadata struct { // SessionLifecycleHandler is a callback for session lifecycle events type SessionLifecycleHandler func(event SessionLifecycleEvent) -// permissionRequestRequest represents the request data for a permission request -type permissionRequestRequest struct { - SessionID string `json:"sessionId"` - Request PermissionRequest `json:"permissionRequest"` -} - -// permissionRequestResponse represents the response to a permission request -type permissionRequestResponse struct { - Result PermissionRequestResult `json:"result"` -} - // createSessionRequest is the request for session.create type createSessionRequest struct { Model string `json:"model,omitempty"` @@ -722,6 +723,14 @@ type deleteSessionResponse struct { Error *string `json:"error,omitempty"` } +// getLastSessionIDRequest is the request for session.getLastId +type getLastSessionIDRequest struct{} + +// getLastSessionIDResponse is the response from session.getLastId +type getLastSessionIDResponse struct { + SessionID *string `json:"sessionId,omitempty"` +} + // getForegroundSessionRequest is the request for session.getForeground type getForegroundSessionRequest struct{} @@ -820,21 +829,6 @@ type sessionEventRequest struct { Event SessionEvent `json:"event"` } -// toolCallRequest represents a tool call request from the server -// to the client for execution. -type toolCallRequest struct { - SessionID string `json:"sessionId"` - ToolCallID string `json:"toolCallId"` - ToolName string `json:"toolName"` - Arguments any `json:"arguments"` -} - -// toolCallResponse represents the response to a tool call request -// from the client back to the server. -type toolCallResponse struct { - Result ToolResult `json:"result"` -} - // userInputRequest represents a request for user input from the agent type userInputRequest struct { SessionID string `json:"sessionId"` diff --git a/go/types_test.go b/go/types_test.go new file mode 100644 index 000000000..190cd913d --- /dev/null +++ b/go/types_test.go @@ -0,0 +1,91 @@ +package copilot + +import ( + "encoding/json" + "testing" +) + +func TestPermissionRequestResultKind_Constants(t *testing.T) { + tests := []struct { + name string + kind PermissionRequestResultKind + expected string + }{ + {"Approved", PermissionRequestResultKindApproved, "approved"}, + {"DeniedByRules", PermissionRequestResultKindDeniedByRules, "denied-by-rules"}, + {"DeniedCouldNotRequestFromUser", PermissionRequestResultKindDeniedCouldNotRequestFromUser, "denied-no-approval-rule-and-could-not-request-from-user"}, + {"DeniedInteractivelyByUser", PermissionRequestResultKindDeniedInteractivelyByUser, "denied-interactively-by-user"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if string(tt.kind) != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, string(tt.kind)) + } + }) + } +} + +func TestPermissionRequestResultKind_CustomValue(t *testing.T) { + custom := PermissionRequestResultKind("custom-kind") + if string(custom) != "custom-kind" { + t.Errorf("expected %q, got %q", "custom-kind", string(custom)) + } +} + +func TestPermissionRequestResult_JSONRoundTrip(t *testing.T) { + tests := []struct { + name string + kind PermissionRequestResultKind + }{ + {"Approved", PermissionRequestResultKindApproved}, + {"DeniedByRules", PermissionRequestResultKindDeniedByRules}, + {"DeniedCouldNotRequestFromUser", PermissionRequestResultKindDeniedCouldNotRequestFromUser}, + {"DeniedInteractivelyByUser", PermissionRequestResultKindDeniedInteractivelyByUser}, + {"Custom", PermissionRequestResultKind("custom")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + original := PermissionRequestResult{Kind: tt.kind} + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var decoded PermissionRequestResult + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if decoded.Kind != tt.kind { + t.Errorf("expected kind %q, got %q", tt.kind, decoded.Kind) + } + }) + } +} + +func TestPermissionRequestResult_JSONDeserialize(t *testing.T) { + jsonStr := `{"kind":"denied-by-rules"}` + var result PermissionRequestResult + if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if result.Kind != PermissionRequestResultKindDeniedByRules { + t.Errorf("expected %q, got %q", PermissionRequestResultKindDeniedByRules, result.Kind) + } +} + +func TestPermissionRequestResult_JSONSerialize(t *testing.T) { + result := PermissionRequestResult{Kind: PermissionRequestResultKindApproved} + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + expected := `{"kind":"approved"}` + if string(data) != expected { + t.Errorf("expected %s, got %s", expected, string(data)) + } +} diff --git a/justfile b/justfile index 5eea5100f..85cd8c61b 100644 --- a/justfile +++ b/justfile @@ -78,6 +78,7 @@ install: @cd python && uv pip install -e ".[dev]" @cd go && go mod download @cd dotnet && dotnet restore + @cd test/harness && npm ci --ignore-scripts @echo "✅ All dependencies installed" # Run interactive SDK playground diff --git a/nodejs/README.md b/nodejs/README.md index 31558b8ab..78a535b76 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -52,10 +52,17 @@ await session.send({ prompt: "What is 2+2?" }); await done; // Clean up -await session.destroy(); +await session.disconnect(); await client.stop(); ``` +Sessions also support `Symbol.asyncDispose` for use with [`await using`](https://github.com/tc39/proposal-explicit-resource-management) (TypeScript 5.2+/Node.js 18.0+): + +```typescript +await using session = await client.createSession({ model: "gpt-5" }); +// session is automatically disconnected when leaving scope +``` + ## API Reference ### CopilotClient @@ -265,9 +272,13 @@ Abort the currently processing message in this session. Get all events/messages from this session. -##### `destroy(): Promise` +##### `disconnect(): Promise` + +Disconnect the session and free resources. Session data on disk is preserved for later resumption. + +##### `destroy(): Promise` *(deprecated)* -Destroy the session and free resources. +Deprecated — use `disconnect()` instead. --- @@ -402,6 +413,19 @@ const session = await client.createSession({ When Copilot invokes `lookup_issue`, the client automatically runs your handler and responds to the CLI. Handlers can return any JSON-serializable value (automatically wrapped), a simple string, or a `ToolResultObject` for full control over result metadata. Raw JSON schemas are also supported if Zod isn't desired. +#### Overriding Built-in Tools + +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), the SDK will throw an error unless you explicitly opt in by setting `overridesBuiltInTool: true`. This flag signals that you intend to replace the built-in tool with your custom implementation. + +```ts +defineTool("edit_file", { + description: "Custom file editor with project-specific validation", + parameters: z.object({ path: z.string(), content: z.string() }), + overridesBuiltInTool: true, + handler: async ({ path, content }) => { /* your logic */ }, +}) +``` + ### System Message Customization Control the system prompt using `systemMessage` in session config: diff --git a/nodejs/examples/basic-example.ts b/nodejs/examples/basic-example.ts index b0b993138..c20a85af0 100644 --- a/nodejs/examples/basic-example.ts +++ b/nodejs/examples/basic-example.ts @@ -41,6 +41,6 @@ const result2 = await session.sendAndWait({ prompt: "Use lookup_fact to tell me console.log("📝 Response:", result2?.data.content); // Clean up -await session.destroy(); +await session.disconnect(); await client.stop(); console.log("✅ Done!"); diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 3cba7c816..78aacd1c0 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.411", + "@github/copilot": "^1.0.2", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -539,9 +539,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -612,9 +612,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -662,26 +662,26 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.411.tgz", - "integrity": "sha512-I3/7gw40Iu1O+kTyNPKJHNqDRyOebjsUW6wJsvSVrOpT0TNa3/lfm8xdS2XUuJWkp+PgEG/PRwF7u3DVNdP7bQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.2.tgz", + "integrity": "sha512-716SIZMYftldVcJay2uZOzsa9ROGGb2Mh2HnxbDxoisFsWNNgZlQXlV7A+PYoGsnAo2Zk/8e1i5SPTscGf2oww==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.411", - "@github/copilot-darwin-x64": "0.0.411", - "@github/copilot-linux-arm64": "0.0.411", - "@github/copilot-linux-x64": "0.0.411", - "@github/copilot-win32-arm64": "0.0.411", - "@github/copilot-win32-x64": "0.0.411" + "@github/copilot-darwin-arm64": "1.0.2", + "@github/copilot-darwin-x64": "1.0.2", + "@github/copilot-linux-arm64": "1.0.2", + "@github/copilot-linux-x64": "1.0.2", + "@github/copilot-win32-arm64": "1.0.2", + "@github/copilot-win32-x64": "1.0.2" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.411.tgz", - "integrity": "sha512-dtr+iHxTS4f8HlV2JT9Fp0FFoxuiPWCnU3XGmrHK+rY6bX5okPC2daU5idvs77WKUGcH8yHTZtfbKYUiMxKosw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-dYoeaTidsphRXyMjvAgpjEbBV41ipICnXURrLFEiATcjC4IY6x2BqPOocrExBYW/Tz2VZvDw51iIZaf6GXrTmw==", "cpu": [ "arm64" ], @@ -695,9 +695,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.411.tgz", - "integrity": "sha512-zhdbQCbPi1L4iHClackSLx8POfklA+NX9RQLuS48HlKi/0KI/JlaDA/bdbIeMR79wjif5t9gnc/m+RTVmHlRtA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.2.tgz", + "integrity": "sha512-8+Z9dYigEfXf0wHl9c2tgFn8Cr6v4RAY8xTgHMI9mZInjQyxVeBXCxbE2VgzUtDUD3a705Ka2d8ZOz05aYtGsg==", "cpu": [ "x64" ], @@ -711,9 +711,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.411.tgz", - "integrity": "sha512-oZYZ7oX/7O+jzdTUcHkfD1A8YnNRW6mlUgdPjUg+5rXC43bwIdyatAnc0ObY21m9h8ghxGqholoLhm5WnGv1LQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.2.tgz", + "integrity": "sha512-ik0Y5aTXOFRPLFrNjZJdtfzkozYqYeJjVXGBAH3Pp1nFZRu/pxJnrnQ1HrqO/LEgQVbJzAjQmWEfMbXdQIxE4Q==", "cpu": [ "arm64" ], @@ -727,9 +727,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.411.tgz", - "integrity": "sha512-nnXrKANmmGnkwa3ROlKdAhVNOx8daeMSE8Xh0o3ybKckFv4s38blhKdcxs0RJQRxgAk4p7XXGlDDKNRhurqF1g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.2.tgz", + "integrity": "sha512-mHSPZjH4nU9rwbfwLxYJ7CQ90jK/Qu1v2CmvBCUPfmuGdVwrpGPHB5FrB+f+b0NEXjmemDWstk2zG53F7ppHfw==", "cpu": [ "x64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.411.tgz", - "integrity": "sha512-h+Bovb2YVCQSeELZOO7zxv8uht45XHcvAkFbRsc1gf9dl109sSUJIcB4KAhs8Aznk28qksxz7kvdSgUWyQBlIA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.2.tgz", + "integrity": "sha512-tLW2CY/vg0fYLp8EuiFhWIHBVzbFCDDpohxT/F/XyMAdTVSZLnopCcxQHv2BOu0CVGrYjlf7YOIwPfAKYml1FA==", "cpu": [ "arm64" ], @@ -759,9 +759,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.411.tgz", - "integrity": "sha512-xmOgi1lGvUBHQJWmq5AK1EP95+Y8xR4TFoK9OCSOaGbQ+LFcX2jF7iavnMolfWwddabew/AMQjsEHlXvbgMG8Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.2.tgz", + "integrity": "sha512-cFlc3xMkKKFRIYR00EEJ2XlYAemeh5EZHsGA8Ir2G0AH+DOevJbomdP1yyCC5gaK/7IyPkHX3sGie5sER2yPvQ==", "cpu": [ "x64" ], @@ -833,29 +833,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1267,27 +1244,27 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", - "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", - "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/type-utils": "8.54.0", - "@typescript-eslint/utils": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -1300,22 +1277,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.54.0", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", - "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "engines": { @@ -1326,19 +1303,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", - "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.54.0", - "@typescript-eslint/types": "^8.54.0", + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "engines": { @@ -1353,14 +1330,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", - "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1371,9 +1348,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", - "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", "dev": true, "license": "MIT", "engines": { @@ -1388,15 +1365,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", - "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -1408,14 +1385,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", "dev": true, "license": "MIT", "engines": { @@ -1427,18 +1404,18 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", - "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.54.0", - "@typescript-eslint/tsconfig-utils": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" @@ -1454,43 +1431,17 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1500,19 +1451,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", - "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1523,13 +1474,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2100,9 +2051,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2348,18 +2299,18 @@ } }, "node_modules/glob": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", - "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.1.2", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2623,9 +2574,9 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -2643,21 +2594,44 @@ } }, "node_modules/minimatch": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", - "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -2669,11 +2643,11 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -2841,9 +2815,9 @@ } }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -2851,7 +2825,7 @@ "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3030,13 +3004,13 @@ } }, "node_modules/rimraf": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", - "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^13.0.0", + "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { @@ -3337,9 +3311,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, diff --git a/nodejs/package.json b/nodejs/package.json index a0c85478b..ccd63582a 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -12,6 +12,10 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" + }, + "./extension": { + "import": "./dist/extension.js", + "types": "./dist/extension.d.ts" } }, "type": "module", @@ -40,7 +44,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.411", + "@github/copilot": "^1.0.2", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index 3272df55b..36f8b7039 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,7 +18,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.411-0", + "@github/copilot": "^0.0.421", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/scripts/get-version.js b/nodejs/scripts/get-version.js index d58ff79d9..784dd0b51 100644 --- a/nodejs/scripts/get-version.js +++ b/nodejs/scripts/get-version.js @@ -5,7 +5,7 @@ * * Usage: * - * node scripts/get-version.js [current|current-prerelease|latest|prerelease] + * node scripts/get-version.js [current|current-prerelease|latest|prerelease|unstable] * * Outputs the version to stdout. */ @@ -32,7 +32,7 @@ async function getLatestVersion(tag) { async function main() { const command = process.argv[2]; - const validCommands = ["current", "current-prerelease", "latest", "prerelease"]; + const validCommands = ["current", "current-prerelease", "latest", "prerelease", "unstable"]; if (!validCommands.includes(command)) { console.error( `Invalid argument, must be one of: ${validCommands.join(", ")}, got: "${command}"` @@ -75,8 +75,16 @@ async function main() { return; } + if (command === "unstable") { + const unstable = await getLatestVersion("unstable"); + if (unstable && semver.gt(unstable, higherVersion)) { + higherVersion = unstable; + } + } + const increment = command === "latest" ? "patch" : "prerelease"; - const prereleaseIdentifier = command === "prerelease" ? "preview" : undefined; + const prereleaseIdentifier = + command === "prerelease" ? "preview" : command === "unstable" ? "unstable" : undefined; const nextVersion = semver.inc(higherVersion, increment, prereleaseIdentifier); if (!nextVersion) { console.error(`Failed to increment version "${higherVersion}" with "${increment}"`); diff --git a/nodejs/scripts/update-protocol-version.ts b/nodejs/scripts/update-protocol-version.ts index d0e3ecc66..46f6189e8 100644 --- a/nodejs/scripts/update-protocol-version.ts +++ b/nodejs/scripts/update-protocol-version.ts @@ -106,7 +106,7 @@ internal static class SdkProtocolVersion /// /// The SDK protocol version. /// - public const int Version = ${version}; + private const int Version = ${version}; /// /// Gets the SDK protocol version. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index b32ff0a75..419f00553 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -46,12 +46,16 @@ import type { Tool, ToolCallRequestPayload, ToolCallResponsePayload, - ToolHandler, - ToolResult, ToolResultObject, TypedSessionLifecycleHandler, } from "./types.js"; +/** + * Minimum protocol version this SDK can communicate with. + * Servers reporting a version below this are rejected. + */ +const MIN_PROTOCOL_VERSION = 2; + /** * Check if value is a Zod schema (has toJSONSchema method) */ @@ -93,7 +97,7 @@ function toJsonSchema(parameters: Tool["parameters"]): Record | * const client = new CopilotClient({ cliUrl: "localhost:3000" }); * * // Create a session - * const session = await client.createSession({ model: "gpt-4" }); + * const session = await client.createSession({ onPermissionRequest: approveAll, model: "gpt-4" }); * * // Send messages and handle responses * session.on((event) => { @@ -104,7 +108,7 @@ function toJsonSchema(parameters: Tool["parameters"]): Record | * await session.send({ prompt: "Hello!" }); * * // Clean up - * await session.destroy(); + * await session.disconnect(); * await client.stop(); * ``` */ @@ -158,6 +162,7 @@ export class CopilotClient { > = new Map(); private _rpc: ReturnType | null = null; private processExitPromise: Promise | null = null; // Rejects when CLI process exits + private negotiatedProtocolVersion: number | null = null; /** * Typed server-scoped RPC methods. @@ -200,6 +205,12 @@ export class CopilotClient { throw new Error("cliUrl is mutually exclusive with useStdio and cliPath"); } + if (options.isChildProcess && (options.cliUrl || options.useStdio === false)) { + throw new Error( + "isChildProcess must be used in conjunction with useStdio and not with cliUrl" + ); + } + // Validate auth options with external server if (options.cliUrl && (options.githubToken || options.useLoggedInUser !== undefined)) { throw new Error( @@ -215,12 +226,17 @@ export class CopilotClient { this.isExternalServer = true; } + if (options.isChildProcess) { + this.isExternalServer = true; + } + this.options = { cliPath: options.cliPath || getBundledCliPath(), cliArgs: options.cliArgs ?? [], cwd: options.cwd ?? process.cwd(), port: options.port || 0, useStdio: options.cliUrl ? false : (options.useStdio ?? true), // Default to stdio unless cliUrl is provided + isChildProcess: options.isChildProcess ?? false, cliUrl: options.cliUrl, logLevel: options.logLevel || "debug", autoStart: options.autoStart ?? true, @@ -331,10 +347,14 @@ export class CopilotClient { * Stops the CLI server and closes all active sessions. * * This method performs graceful cleanup: - * 1. Destroys all active sessions with retry logic + * 1. Closes all active sessions (releases in-memory resources) * 2. Closes the JSON-RPC connection * 3. Terminates the CLI server process (if spawned by this client) * + * Note: session data on disk is preserved, so sessions can be resumed later. + * To permanently remove session data before stopping, call + * {@link deleteSession} for each session first. + * * @returns A promise that resolves with an array of errors encountered during cleanup. * An empty array indicates all cleanup succeeded. * @@ -349,7 +369,7 @@ export class CopilotClient { async stop(): Promise { const errors: Error[] = []; - // Destroy all active sessions with retry logic + // Disconnect all active sessions with retry logic for (const session of this.sessions.values()) { const sessionId = session.sessionId; let lastError: Error | null = null; @@ -357,7 +377,7 @@ export class CopilotClient { // Try up to 3 times with exponential backoff for (let attempt = 1; attempt <= 3; attempt++) { try { - await session.destroy(); + await session.disconnect(); lastError = null; break; // Success } catch (error) { @@ -374,7 +394,7 @@ export class CopilotClient { if (lastError) { errors.push( new Error( - `Failed to destroy session ${sessionId} after 3 attempts: ${lastError.message}` + `Failed to disconnect session ${sessionId} after 3 attempts: ${lastError.message}` ) ); } @@ -539,10 +559,11 @@ export class CopilotClient { * @example * ```typescript * // Basic session - * const session = await client.createSession(); + * const session = await client.createSession({ onPermissionRequest: approveAll }); * * // Session with model and tools * const session = await client.createSession({ + * onPermissionRequest: approveAll, * model: "gpt-4", * tools: [{ * name: "get_weather", @@ -553,7 +574,13 @@ export class CopilotClient { * }); * ``` */ - async createSession(config: SessionConfig = {}): Promise { + async createSession(config: SessionConfig): Promise { + if (!config?.onPermissionRequest) { + throw new Error( + "An onPermissionRequest handler is required when creating a session. For example, to allow all permissions, use { onPermissionRequest: approveAll }." + ); + } + if (!this.connection) { if (this.options.autoStart) { await this.start(); @@ -571,6 +598,7 @@ export class CopilotClient { name: tool.name, description: tool.description, parameters: toJsonSchema(tool.parameters), + overridesBuiltInTool: tool.overridesBuiltInTool, })), systemMessage: config.systemMessage, availableTools: config.availableTools, @@ -596,9 +624,7 @@ export class CopilotClient { }; const session = new CopilotSession(sessionId, this.connection!, workspacePath); session.registerTools(config.tools); - if (config.onPermissionRequest) { - session.registerPermissionHandler(config.onPermissionRequest); - } + session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { session.registerUserInputHandler(config.onUserInputRequest); } @@ -625,18 +651,22 @@ export class CopilotClient { * @example * ```typescript * // Resume a previous session - * const session = await client.resumeSession("session-123"); + * const session = await client.resumeSession("session-123", { onPermissionRequest: approveAll }); * * // Resume with new tools * const session = await client.resumeSession("session-123", { + * onPermissionRequest: approveAll, * tools: [myNewTool] * }); * ``` */ - async resumeSession( - sessionId: string, - config: ResumeSessionConfig = {} - ): Promise { + async resumeSession(sessionId: string, config: ResumeSessionConfig): Promise { + if (!config?.onPermissionRequest) { + throw new Error( + "An onPermissionRequest handler is required when resuming a session. For example, to allow all permissions, use { onPermissionRequest: approveAll }." + ); + } + if (!this.connection) { if (this.options.autoStart) { await this.start(); @@ -657,6 +687,7 @@ export class CopilotClient { name: tool.name, description: tool.description, parameters: toJsonSchema(tool.parameters), + overridesBuiltInTool: tool.overridesBuiltInTool, })), provider: config.provider, requestPermission: true, @@ -680,9 +711,7 @@ export class CopilotClient { }; const session = new CopilotSession(resumedSessionId, this.connection!, workspacePath); session.registerTools(config.tools); - if (config.onPermissionRequest) { - session.registerPermissionHandler(config.onPermissionRequest); - } + session.registerPermissionHandler(config.onPermissionRequest); if (config.onUserInputRequest) { session.registerUserInputHandler(config.onUserInputRequest); } @@ -702,7 +731,7 @@ export class CopilotClient { * @example * ```typescript * if (client.getState() === "connected") { - * const session = await client.createSession(); + * const session = await client.createSession({ onPermissionRequest: approveAll }); * } * ``` */ @@ -804,10 +833,11 @@ export class CopilotClient { } /** - * Verify that the server's protocol version matches the SDK's expected version + * Verify that the server's protocol version is within the supported range + * and store the negotiated version. */ private async verifyProtocolVersion(): Promise { - const expectedVersion = getSdkProtocolVersion(); + const maxVersion = getSdkProtocolVersion(); // Race ping against process exit to detect early CLI failures let pingResult: Awaited>; @@ -821,17 +851,19 @@ export class CopilotClient { if (serverVersion === undefined) { throw new Error( - `SDK protocol version mismatch: SDK expects version ${expectedVersion}, but server does not report a protocol version. ` + + `SDK protocol version mismatch: SDK supports versions ${MIN_PROTOCOL_VERSION}-${maxVersion}, but server does not report a protocol version. ` + `Please update your server to ensure compatibility.` ); } - if (serverVersion !== expectedVersion) { + if (serverVersion < MIN_PROTOCOL_VERSION || serverVersion > maxVersion) { throw new Error( - `SDK protocol version mismatch: SDK expects version ${expectedVersion}, but server reports version ${serverVersion}. ` + + `SDK protocol version mismatch: SDK supports versions ${MIN_PROTOCOL_VERSION}-${maxVersion}, but server reports version ${serverVersion}. ` + `Please update your SDK or server to ensure compatibility.` ); } + + this.negotiatedProtocolVersion = serverVersion; } /** @@ -847,7 +879,7 @@ export class CopilotClient { * ```typescript * const lastId = await client.getLastSessionId(); * if (lastId) { - * const session = await client.resumeSession(lastId); + * const session = await client.resumeSession(lastId, { onPermissionRequest: approveAll }); * } * ``` */ @@ -861,10 +893,12 @@ export class CopilotClient { } /** - * Deletes a session and its data from disk. + * Permanently deletes a session and all its data from disk, including + * conversation history, planning state, and artifacts. * - * This permanently removes the session and all its conversation history. - * The session cannot be resumed after deletion. + * Unlike {@link CopilotSession.disconnect}, which only releases in-memory + * resources and preserves session data for later resumption, this method + * is irreversible. The session cannot be resumed after deletion. * * @param sessionId - The ID of the session to delete * @returns A promise that resolves when the session is deleted @@ -1240,17 +1274,19 @@ export class CopilotClient { * Connect to the CLI server (via socket or stdio) */ private async connectToServer(): Promise { - if (this.options.useStdio) { - return this.connectViaStdio(); + if (this.options.isChildProcess) { + return this.connectToParentProcessViaStdio(); + } else if (this.options.useStdio) { + return this.connectToChildProcessViaStdio(); } else { return this.connectViaTcp(); } } /** - * Connect via stdio pipes + * Connect to child via stdio pipes */ - private async connectViaStdio(): Promise { + private async connectToChildProcessViaStdio(): Promise { if (!this.cliProcess) { throw new Error("CLI process not started"); } @@ -1272,6 +1308,24 @@ export class CopilotClient { this.connection.listen(); } + /** + * Connect to parent via stdio pipes + */ + private async connectToParentProcessViaStdio(): Promise { + if (this.cliProcess) { + throw new Error("CLI child process was unexpectedly started in parent process mode"); + } + + // Create JSON-RPC connection over stdin/stdout + this.connection = createMessageConnection( + new StreamMessageReader(process.stdin), + new StreamMessageWriter(process.stdout) + ); + + this.attachConnectionHandlers(); + this.connection.listen(); + } + /** * Connect to the CLI server via TCP socket */ @@ -1314,10 +1368,15 @@ export class CopilotClient { this.handleSessionLifecycleNotification(notification); }); + // Protocol v3 servers send tool calls and permission requests as broadcast events + // (external_tool.requested / permission.requested) handled in CopilotSession._dispatchEvent. + // Protocol v2 servers use the older tool.call / permission.request RPC model instead. + // We always register v2 adapters because handlers are set up before version negotiation; + // a v3 server will simply never send these requests. this.connection.onRequest( "tool.call", async (params: ToolCallRequestPayload): Promise => - await this.handleToolCallRequest(params) + await this.handleToolCallRequestV2(params) ); this.connection.onRequest( @@ -1325,7 +1384,7 @@ export class CopilotClient { async (params: { sessionId: string; permissionRequest: unknown; - }): Promise<{ result: unknown }> => await this.handlePermissionRequest(params) + }): Promise<{ result: unknown }> => await this.handlePermissionRequestV2(params) ); this.connection.onRequest( @@ -1412,7 +1471,65 @@ export class CopilotClient { } } - private async handleToolCallRequest( + private async handleUserInputRequest(params: { + sessionId: string; + question: string; + choices?: string[]; + allowFreeform?: boolean; + }): Promise<{ answer: string; wasFreeform: boolean }> { + if ( + !params || + typeof params.sessionId !== "string" || + typeof params.question !== "string" + ) { + throw new Error("Invalid user input request payload"); + } + + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + + const result = await session._handleUserInputRequest({ + question: params.question, + choices: params.choices, + allowFreeform: params.allowFreeform, + }); + return result; + } + + private async handleHooksInvoke(params: { + sessionId: string; + hookType: string; + input: unknown; + }): Promise<{ output?: unknown }> { + if ( + !params || + typeof params.sessionId !== "string" || + typeof params.hookType !== "string" + ) { + throw new Error("Invalid hooks invoke payload"); + } + + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + + const output = await session._handleHooksInvoke(params.hookType, params.input); + return { output }; + } + + // ======================================================================== + // Protocol v2 backward-compatibility adapters + // ======================================================================== + + /** + * Handles a v2-style tool.call RPC request from the server. + * Looks up the session and tool handler, executes it, and returns the result + * in the v2 response format. + */ + private async handleToolCallRequestV2( params: ToolCallRequestPayload ): Promise { if ( @@ -1431,31 +1548,29 @@ export class CopilotClient { const handler = session.getToolHandler(params.toolName); if (!handler) { - return { result: this.buildUnsupportedToolResult(params.toolName) }; + return { + result: { + textResultForLlm: `Tool '${params.toolName}' is not supported by this client instance.`, + resultType: "failure", + error: `tool '${params.toolName}' not supported`, + toolTelemetry: {}, + }, + }; } - return await this.executeToolCall(handler, params); - } - - private async executeToolCall( - handler: ToolHandler, - request: ToolCallRequestPayload - ): Promise { try { const invocation = { - sessionId: request.sessionId, - toolCallId: request.toolCallId, - toolName: request.toolName, - arguments: request.arguments, + sessionId: params.sessionId, + toolCallId: params.toolCallId, + toolName: params.toolName, + arguments: params.arguments, }; - const result = await handler(request.arguments, invocation); - - return { result: this.normalizeToolResult(result) }; + const result = await handler(params.arguments, invocation); + return { result: this.normalizeToolResultV2(result) }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { result: { - // Don't expose detailed error information to the LLM for security reasons textResultForLlm: "Invoking this tool produced an error. Detailed information is not available.", resultType: "failure", @@ -1466,7 +1581,10 @@ export class CopilotClient { } } - private async handlePermissionRequest(params: { + /** + * Handles a v2-style permission.request RPC request from the server. + */ + private async handlePermissionRequestV2(params: { sessionId: string; permissionRequest: unknown; }): Promise<{ result: unknown }> { @@ -1480,10 +1598,9 @@ export class CopilotClient { } try { - const result = await session._handlePermissionRequest(params.permissionRequest); + const result = await session._handlePermissionRequestV2(params.permissionRequest); return { result }; } catch (_error) { - // If permission handler fails, deny the permission return { result: { kind: "denied-no-approval-rule-and-could-not-request-from-user", @@ -1492,56 +1609,7 @@ export class CopilotClient { } } - private async handleUserInputRequest(params: { - sessionId: string; - question: string; - choices?: string[]; - allowFreeform?: boolean; - }): Promise<{ answer: string; wasFreeform: boolean }> { - if ( - !params || - typeof params.sessionId !== "string" || - typeof params.question !== "string" - ) { - throw new Error("Invalid user input request payload"); - } - - const session = this.sessions.get(params.sessionId); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - - const result = await session._handleUserInputRequest({ - question: params.question, - choices: params.choices, - allowFreeform: params.allowFreeform, - }); - return result; - } - - private async handleHooksInvoke(params: { - sessionId: string; - hookType: string; - input: unknown; - }): Promise<{ output?: unknown }> { - if ( - !params || - typeof params.sessionId !== "string" || - typeof params.hookType !== "string" - ) { - throw new Error("Invalid hooks invoke payload"); - } - - const session = this.sessions.get(params.sessionId); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - - const output = await session._handleHooksInvoke(params.hookType, params.input); - return { output }; - } - - private normalizeToolResult(result: unknown): ToolResultObject { + private normalizeToolResultV2(result: unknown): ToolResultObject { if (result === undefined || result === null) { return { textResultForLlm: "Tool returned no result", @@ -1551,12 +1619,10 @@ export class CopilotClient { }; } - // ToolResultObject passes through directly (duck-type check) if (this.isToolResultObject(result)) { return result; } - // Everything else gets wrapped as a successful ToolResultObject const textResult = typeof result === "string" ? result : JSON.stringify(result); return { textResultForLlm: textResult, @@ -1575,15 +1641,6 @@ export class CopilotClient { ); } - private buildUnsupportedToolResult(toolName: string): ToolResult { - return { - textResultForLlm: `Tool '${toolName}' is not supported by this client instance.`, - resultType: "failure", - error: `tool '${toolName}' not supported`, - toolTelemetry: {}, - }; - } - /** * Wraps a ProtocolConnection to provide MessageConnection interface compatibility. * This allows ACP connections to work with the existing session management code. diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts new file mode 100644 index 000000000..b84fb2b6f --- /dev/null +++ b/nodejs/src/extension.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { CopilotClient } from "./client.js"; + +export const extension = new CopilotClient({ isChildProcess: true }); diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts index 12c992bd6..c230348e0 100644 --- a/nodejs/src/generated/rpc.ts +++ b/nodejs/src/generated/rpc.ts @@ -45,11 +45,11 @@ export interface ModelsListResult { */ capabilities: { supports: { - vision: boolean; + vision?: boolean; /** * Whether this model supports reasoning effort configuration */ - reasoningEffort: boolean; + reasoningEffort?: boolean; }; limits: { max_prompt_tokens?: number; @@ -209,13 +209,17 @@ export interface SessionModeSetParams { export interface SessionPlanReadResult { /** - * Whether plan.md exists in the workspace + * Whether the plan file exists in the workspace */ exists: boolean; /** - * The content of plan.md, or null if it does not exist + * The content of the plan file, or null if it does not exist */ content: string | null; + /** + * Absolute file path of the plan file, or null if workspace is not enabled + */ + path: string | null; } export interface SessionPlanReadParams { @@ -233,7 +237,7 @@ export interface SessionPlanUpdateParams { */ sessionId: string; /** - * The new content for plan.md + * The new content for the plan file */ content: string; } @@ -314,6 +318,177 @@ export interface SessionFleetStartParams { prompt?: string; } +export interface SessionAgentListResult { + /** + * Available custom agents + */ + agents: { + /** + * Unique identifier of the custom agent + */ + name: string; + /** + * Human-readable display name + */ + displayName: string; + /** + * Description of the agent's purpose + */ + description: string; + }[]; +} + +export interface SessionAgentListParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionAgentGetCurrentResult { + /** + * Currently selected custom agent, or null if using the default agent + */ + agent: { + /** + * Unique identifier of the custom agent + */ + name: string; + /** + * Human-readable display name + */ + displayName: string; + /** + * Description of the agent's purpose + */ + description: string; + } | null; +} + +export interface SessionAgentGetCurrentParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionAgentSelectResult { + /** + * The newly selected custom agent + */ + agent: { + /** + * Unique identifier of the custom agent + */ + name: string; + /** + * Human-readable display name + */ + displayName: string; + /** + * Description of the agent's purpose + */ + description: string; + }; +} + +export interface SessionAgentSelectParams { + /** + * Target session identifier + */ + sessionId: string; + /** + * Name of the custom agent to select + */ + name: string; +} + +export interface SessionAgentDeselectResult {} + +export interface SessionAgentDeselectParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionCompactionCompactResult { + /** + * Whether compaction completed successfully + */ + success: boolean; + /** + * Number of tokens freed by compaction + */ + tokensRemoved: number; + /** + * Number of messages removed during compaction + */ + messagesRemoved: number; +} + +export interface SessionCompactionCompactParams { + /** + * Target session identifier + */ + sessionId: string; +} + +export interface SessionToolsHandlePendingToolCallResult { + success: boolean; +} + +export interface SessionToolsHandlePendingToolCallParams { + /** + * Target session identifier + */ + sessionId: string; + requestId: string; + result?: + | string + | { + textResultForLlm: string; + resultType?: string; + error?: string; + toolTelemetry?: { + [k: string]: unknown; + }; + }; + error?: string; +} + +export interface SessionPermissionsHandlePendingPermissionRequestResult { + success: boolean; +} + +export interface SessionPermissionsHandlePendingPermissionRequestParams { + /** + * Target session identifier + */ + sessionId: string; + requestId: string; + result: + | { + kind: "approved"; + } + | { + kind: "denied-by-rules"; + rules: unknown[]; + } + | { + kind: "denied-no-approval-rule-and-could-not-request-from-user"; + } + | { + kind: "denied-interactively-by-user"; + feedback?: string; + } + | { + kind: "denied-by-content-exclusion-policy"; + path: string; + message: string; + }; +} + /** Create typed server-scoped RPC methods (no session required). */ export function createServerRpc(connection: MessageConnection) { return { @@ -369,5 +544,27 @@ export function createSessionRpc(connection: MessageConnection, sessionId: strin start: async (params: Omit): Promise => connection.sendRequest("session.fleet.start", { sessionId, ...params }), }, + agent: { + list: async (): Promise => + connection.sendRequest("session.agent.list", { sessionId }), + getCurrent: async (): Promise => + connection.sendRequest("session.agent.getCurrent", { sessionId }), + select: async (params: Omit): Promise => + connection.sendRequest("session.agent.select", { sessionId, ...params }), + deselect: async (): Promise => + connection.sendRequest("session.agent.deselect", { sessionId }), + }, + compaction: { + compact: async (): Promise => + connection.sendRequest("session.compaction.compact", { sessionId }), + }, + tools: { + handlePendingToolCall: async (params: Omit): Promise => + connection.sendRequest("session.tools.handlePendingToolCall", { sessionId, ...params }), + }, + permissions: { + handlePendingPermissionRequest: async (params: Omit): Promise => + connection.sendRequest("session.permissions.handlePendingPermissionRequest", { sessionId, ...params }), + }, }; } diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts index 032a1723d..cf87e1025 100644 --- a/nodejs/src/generated/session-events.ts +++ b/nodejs/src/generated/session-events.ts @@ -5,699 +5,2703 @@ export type SessionEvent = | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.start"; data: { + /** + * Unique identifier for the session + */ sessionId: string; + /** + * Schema version number for the session event format + */ version: number; + /** + * Identifier of the software producing the events (e.g., "copilot-agent") + */ producer: string; + /** + * Version string of the Copilot application + */ copilotVersion: string; + /** + * ISO 8601 timestamp when the session was created + */ startTime: string; + /** + * Model selected at session creation time, if any + */ selectedModel?: string; + /** + * Working directory and git context at session start + */ context?: { + /** + * Current working directory path + */ cwd: string; + /** + * Root directory of the git repository, resolved via git rev-parse + */ gitRoot?: string; + /** + * Repository identifier in "owner/name" format, derived from the git remote URL + */ repository?: string; + /** + * Current git branch name + */ branch?: string; }; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.resume"; data: { + /** + * ISO 8601 timestamp when the session was resumed + */ resumeTime: string; + /** + * Total number of persisted events in the session at the time of resume + */ eventCount: number; + /** + * Updated working directory and git context at resume time + */ context?: { + /** + * Current working directory path + */ cwd: string; + /** + * Root directory of the git repository, resolved via git rev-parse + */ gitRoot?: string; + /** + * Repository identifier in "owner/name" format, derived from the git remote URL + */ repository?: string; + /** + * Current git branch name + */ branch?: string; }; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.error"; data: { + /** + * Category of error (e.g., "authentication", "authorization", "quota", "rate_limit", "query") + */ errorType: string; + /** + * Human-readable error message + */ message: string; + /** + * Error stack trace, when available + */ stack?: string; + /** + * HTTP status code from the upstream request, if applicable + */ statusCode?: number; + /** + * GitHub request tracing ID (x-github-request-id header) for correlating with server-side logs + */ providerCallId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "session.idle"; - data: {}; + /** + * Payload indicating the agent is idle; includes any background tasks still in flight + */ + data: { + /** + * Background tasks still running when the agent became idle + */ + backgroundTasks?: { + /** + * Currently running background agents + */ + agents: { + /** + * Unique identifier of the background agent + */ + agentId: string; + /** + * Type of the background agent + */ + agentType: string; + /** + * Human-readable description of the agent task + */ + description?: string; + }[]; + /** + * Currently running background shell commands + */ + shells: { + /** + * Unique identifier of the background shell + */ + shellId: string; + /** + * Human-readable description of the shell command + */ + description?: string; + }[]; + }; + }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "session.title_changed"; data: { + /** + * The new display title for the session + */ title: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.info"; data: { + /** + * Category of informational message (e.g., "notification", "timing", "context_window", "mcp", "snapshot", "configuration", "authentication", "model") + */ infoType: string; + /** + * Human-readable informational message for display in the timeline + */ message: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.warning"; data: { + /** + * Category of warning (e.g., "subscription", "policy", "mcp") + */ warningType: string; + /** + * Human-readable warning message for display in the timeline + */ message: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.model_change"; data: { + /** + * Model that was previously selected, if any + */ previousModel?: string; + /** + * Newly selected model identifier + */ newModel: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.mode_changed"; data: { + /** + * Agent mode before the change (e.g., "interactive", "plan", "autopilot") + */ previousMode: string; + /** + * Agent mode after the change (e.g., "interactive", "plan", "autopilot") + */ newMode: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.plan_changed"; data: { + /** + * The type of operation performed on the plan file + */ operation: "create" | "update" | "delete"; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.workspace_file_changed"; data: { /** - * Relative path within the workspace files directory + * Relative path within the session workspace files directory */ path: string; + /** + * Whether the file was newly created or updated + */ operation: "create" | "update"; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.handoff"; data: { + /** + * ISO 8601 timestamp when the handoff occurred + */ handoffTime: string; + /** + * Origin type of the session being handed off + */ sourceType: "remote" | "local"; + /** + * Repository context for the handed-off session + */ repository?: { + /** + * Repository owner (user or organization) + */ owner: string; + /** + * Repository name + */ name: string; + /** + * Git branch name, if applicable + */ branch?: string; }; + /** + * Additional context information for the handoff + */ context?: string; + /** + * Summary of the work done in the source session + */ summary?: string; + /** + * Session ID of the remote session being handed off + */ remoteSessionId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.truncation"; data: { + /** + * Maximum token count for the model's context window + */ tokenLimit: number; + /** + * Total tokens in conversation messages before truncation + */ preTruncationTokensInMessages: number; + /** + * Number of conversation messages before truncation + */ preTruncationMessagesLength: number; + /** + * Total tokens in conversation messages after truncation + */ postTruncationTokensInMessages: number; + /** + * Number of conversation messages after truncation + */ postTruncationMessagesLength: number; + /** + * Number of tokens removed by truncation + */ tokensRemovedDuringTruncation: number; + /** + * Number of messages removed by truncation + */ messagesRemovedDuringTruncation: number; + /** + * Identifier of the component that performed truncation (e.g., "BasicTruncator") + */ performedBy: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "session.snapshot_rewind"; data: { + /** + * Event ID that was rewound to; all events after this one were removed + */ upToEventId: string; + /** + * Number of events that were removed by the rewind + */ eventsRemoved: number; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; - ephemeral: true; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ + ephemeral?: boolean; type: "session.shutdown"; data: { + /** + * Whether the session ended normally ("routine") or due to a crash/fatal error ("error") + */ shutdownType: "routine" | "error"; + /** + * Error description when shutdownType is "error" + */ errorReason?: string; + /** + * Total number of premium API requests used during the session + */ totalPremiumRequests: number; + /** + * Cumulative time spent in API calls during the session, in milliseconds + */ totalApiDurationMs: number; + /** + * Unix timestamp (milliseconds) when the session started + */ sessionStartTime: number; + /** + * Aggregate code change metrics for the session + */ codeChanges: { + /** + * Total number of lines added during the session + */ linesAdded: number; + /** + * Total number of lines removed during the session + */ linesRemoved: number; + /** + * List of file paths that were modified during the session + */ filesModified: string[]; }; + /** + * Per-model usage breakdown, keyed by model identifier + */ modelMetrics: { [k: string]: { + /** + * Request count and cost metrics + */ requests: { + /** + * Total number of API requests made to this model + */ count: number; + /** + * Cumulative cost multiplier for requests to this model + */ cost: number; }; + /** + * Token usage breakdown + */ usage: { + /** + * Total input tokens consumed across all requests to this model + */ inputTokens: number; + /** + * Total output tokens produced across all requests to this model + */ outputTokens: number; + /** + * Total tokens read from prompt cache across all requests + */ cacheReadTokens: number; + /** + * Total tokens written to prompt cache across all requests + */ cacheWriteTokens: number; }; }; }; + /** + * Model that was selected at the time of shutdown + */ currentModel?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.context_changed"; data: { + /** + * Current working directory path + */ cwd: string; + /** + * Root directory of the git repository, resolved via git rev-parse + */ gitRoot?: string; + /** + * Repository identifier in "owner/name" format, derived from the git remote URL + */ repository?: string; + /** + * Current git branch name + */ branch?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "session.usage_info"; data: { + /** + * Maximum token count for the model's context window + */ tokenLimit: number; + /** + * Current number of tokens in the context window + */ currentTokens: number; + /** + * Current number of messages in the conversation + */ messagesLength: number; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.compaction_start"; + /** + * Empty payload; the event signals that LLM-powered conversation compaction has begun + */ data: {}; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "session.compaction_complete"; data: { + /** + * Whether compaction completed successfully + */ success: boolean; + /** + * Error message if compaction failed + */ error?: string; + /** + * Total tokens in conversation before compaction + */ preCompactionTokens?: number; + /** + * Total tokens in conversation after compaction + */ postCompactionTokens?: number; + /** + * Number of messages before compaction + */ preCompactionMessagesLength?: number; + /** + * Number of messages removed during compaction + */ messagesRemoved?: number; + /** + * Number of tokens removed during compaction + */ tokensRemoved?: number; + /** + * LLM-generated summary of the compacted conversation history + */ summaryContent?: string; + /** + * Checkpoint snapshot number created for recovery + */ checkpointNumber?: number; + /** + * File path where the checkpoint was stored + */ checkpointPath?: string; + /** + * Token usage breakdown for the compaction LLM call + */ compactionTokensUsed?: { + /** + * Input tokens consumed by the compaction LLM call + */ input: number; + /** + * Output tokens produced by the compaction LLM call + */ output: number; + /** + * Cached input tokens reused in the compaction LLM call + */ cachedInput: number; }; + /** + * GitHub request tracing ID (x-github-request-id header) for the compaction LLM call + */ requestId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ + ephemeral?: boolean; + type: "session.task_complete"; + data: { + /** + * Optional summary of the completed task, provided by the agent + */ + summary?: string; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "user.message"; data: { + /** + * The user's message text as displayed in the timeline + */ content: string; + /** + * Transformed version of the message sent to the model, with XML wrapping, timestamps, and other augmentations for prompt caching + */ transformedContent?: string; + /** + * Files, selections, or GitHub references attached to the message + */ attachments?: ( | { type: "file"; + /** + * Absolute file or directory path + */ path: string; + /** + * User-facing display name for the attachment + */ displayName: string; + /** + * Optional line range to scope the attachment to a specific section of the file + */ lineRange?: { + /** + * Start line number (1-based) + */ start: number; + /** + * End line number (1-based, inclusive) + */ end: number; }; } | { type: "directory"; + /** + * Absolute file or directory path + */ path: string; + /** + * User-facing display name for the attachment + */ displayName: string; + /** + * Optional line range to scope the attachment to a specific section of the file + */ lineRange?: { + /** + * Start line number (1-based) + */ start: number; + /** + * End line number (1-based, inclusive) + */ end: number; }; } | { + /** + * Attachment type discriminator + */ type: "selection"; + /** + * Absolute path to the file containing the selection + */ filePath: string; + /** + * User-facing display name for the selection + */ displayName: string; + /** + * The selected text content + */ text: string; + /** + * Position range of the selection within the file + */ selection: { start: { + /** + * Start line number (0-based) + */ line: number; + /** + * Start character offset within the line (0-based) + */ character: number; }; end: { + /** + * End line number (0-based) + */ line: number; + /** + * End character offset within the line (0-based) + */ character: number; }; }; } + | { + /** + * Attachment type discriminator + */ + type: "github_reference"; + /** + * Issue, pull request, or discussion number + */ + number: number; + /** + * Title of the referenced item + */ + title: string; + /** + * Type of GitHub reference + */ + referenceType: "issue" | "pr" | "discussion"; + /** + * Current state of the referenced item (e.g., open, closed, merged) + */ + state: string; + /** + * URL to the referenced item on GitHub + */ + url: string; + } )[]; + /** + * Origin of this message, used for timeline filtering (e.g., "skill-pdf" for skill-injected messages that should be hidden from the user) + */ source?: string; + /** + * The agent mode that was active when this message was sent + */ agentMode?: "interactive" | "plan" | "autopilot" | "shell"; + /** + * CAPI interaction ID for correlating this user message with its turn + */ + interactionId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "pending_messages.modified"; + /** + * Empty payload; the event signals that the pending message queue has changed + */ data: {}; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "assistant.turn_start"; data: { + /** + * Identifier for this turn within the agentic loop, typically a stringified turn number + */ turnId: string; + /** + * CAPI interaction ID for correlating this turn with upstream telemetry + */ + interactionId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "assistant.intent"; data: { + /** + * Short description of what the agent is currently doing or planning to do + */ intent: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "assistant.reasoning"; data: { + /** + * Unique identifier for this reasoning block + */ reasoningId: string; + /** + * The complete extended thinking text from the model + */ content: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "assistant.reasoning_delta"; data: { + /** + * Reasoning block ID this delta belongs to, matching the corresponding assistant.reasoning event + */ reasoningId: string; + /** + * Incremental text chunk to append to the reasoning content + */ deltaContent: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "assistant.streaming_delta"; + data: { + /** + * Cumulative total bytes received from the streaming response so far + */ + totalResponseSizeBytes: number; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "assistant.message"; data: { + /** + * Unique identifier for this assistant message + */ messageId: string; + /** + * The assistant's text response content + */ content: string; + /** + * Tool invocations requested by the assistant in this message + */ toolRequests?: { + /** + * Unique identifier for this tool call + */ toolCallId: string; + /** + * Name of the tool being invoked + */ name: string; - arguments?: unknown; + /** + * Arguments to pass to the tool, format depends on the tool + */ + arguments?: { + [k: string]: unknown; + }; + /** + * Tool call type: "function" for standard tool calls, "custom" for grammar-based tool calls. Defaults to "function" when absent. + */ type?: "function" | "custom"; }[]; + /** + * Opaque/encrypted extended thinking data from Anthropic models. Session-bound and stripped on resume. + */ reasoningOpaque?: string; + /** + * Readable reasoning text from the model's extended thinking + */ reasoningText?: string; + /** + * Encrypted reasoning content from OpenAI models. Session-bound and stripped on resume. + */ encryptedContent?: string; + /** + * Generation phase for phased-output models (e.g., thinking vs. response phases) + */ phase?: string; + /** + * Actual output token count from the API response (completion_tokens), used for accurate token accounting + */ + outputTokens?: number; + /** + * CAPI interaction ID for correlating this message with upstream telemetry + */ + interactionId?: string; + /** + * Tool call ID of the parent tool invocation when this event originates from a sub-agent + */ parentToolCallId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "assistant.message_delta"; data: { + /** + * Message ID this delta belongs to, matching the corresponding assistant.message event + */ messageId: string; + /** + * Incremental text chunk to append to the message content + */ deltaContent: string; - totalResponseSizeBytes?: number; + /** + * Tool call ID of the parent tool invocation when this event originates from a sub-agent + */ parentToolCallId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "assistant.turn_end"; data: { + /** + * Identifier of the turn that has ended, matching the corresponding assistant.turn_start event + */ turnId: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "assistant.usage"; data: { + /** + * Model identifier used for this API call + */ model: string; + /** + * Number of input tokens consumed + */ inputTokens?: number; + /** + * Number of output tokens produced + */ outputTokens?: number; + /** + * Number of tokens read from prompt cache + */ cacheReadTokens?: number; + /** + * Number of tokens written to prompt cache + */ cacheWriteTokens?: number; + /** + * Model multiplier cost for billing purposes + */ cost?: number; + /** + * Duration of the API call in milliseconds + */ duration?: number; + /** + * What initiated this API call (e.g., "sub-agent"); absent for user-initiated calls + */ initiator?: string; + /** + * Completion ID from the model provider (e.g., chatcmpl-abc123) + */ apiCallId?: string; + /** + * GitHub request tracing ID (x-github-request-id header) for server-side log correlation + */ providerCallId?: string; + /** + * Parent tool call ID when this usage originates from a sub-agent + */ parentToolCallId?: string; + /** + * Per-quota resource usage snapshots, keyed by quota identifier + */ quotaSnapshots?: { [k: string]: { + /** + * Whether the user has an unlimited usage entitlement + */ isUnlimitedEntitlement: boolean; + /** + * Total requests allowed by the entitlement + */ entitlementRequests: number; + /** + * Number of requests already consumed + */ usedRequests: number; + /** + * Whether usage is still permitted after quota exhaustion + */ usageAllowedWithExhaustedQuota: boolean; + /** + * Number of requests over the entitlement limit + */ overage: number; + /** + * Whether overage is allowed when quota is exhausted + */ overageAllowedWithExhaustedQuota: boolean; + /** + * Percentage of quota remaining (0.0 to 1.0) + */ remainingPercentage: number; + /** + * Date when the quota resets + */ resetDate?: string; }; }; + /** + * Per-request cost and usage data from the CAPI copilot_usage response field + */ + copilotUsage?: { + /** + * Itemized token usage breakdown + */ + tokenDetails: { + /** + * Number of tokens in this billing batch + */ + batchSize: number; + /** + * Cost per batch of tokens + */ + costPerBatch: number; + /** + * Total token count for this entry + */ + tokenCount: number; + /** + * Token category (e.g., "input", "output") + */ + tokenType: string; + }[]; + /** + * Total cost in nano-AIU (AI Units) for this request + */ + totalNanoAiu: number; + }; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "abort"; data: { + /** + * Reason the current turn was aborted (e.g., "user initiated") + */ reason: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "tool.user_requested"; data: { - toolCallId: string; + /** + * Unique identifier for this tool call + */ + toolCallId: string; + /** + * Name of the tool the user wants to invoke + */ toolName: string; - arguments?: unknown; + /** + * Arguments for the tool invocation + */ + arguments?: { + [k: string]: unknown; + }; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "tool.execution_start"; data: { + /** + * Unique identifier for this tool call + */ toolCallId: string; + /** + * Name of the tool being executed + */ toolName: string; - arguments?: unknown; + /** + * Arguments passed to the tool + */ + arguments?: { + [k: string]: unknown; + }; + /** + * Name of the MCP server hosting this tool, when the tool is an MCP tool + */ mcpServerName?: string; + /** + * Original tool name on the MCP server, when the tool is an MCP tool + */ mcpToolName?: string; + /** + * Tool call ID of the parent tool invocation when this event originates from a sub-agent + */ parentToolCallId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "tool.execution_partial_result"; data: { + /** + * Tool call ID this partial result belongs to + */ toolCallId: string; + /** + * Incremental output chunk from the running tool + */ partialOutput: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; ephemeral: true; type: "tool.execution_progress"; data: { + /** + * Tool call ID this progress notification belongs to + */ toolCallId: string; + /** + * Human-readable progress status message (e.g., from an MCP server) + */ progressMessage: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "tool.execution_complete"; data: { + /** + * Unique identifier for the completed tool call + */ toolCallId: string; + /** + * Whether the tool execution completed successfully + */ success: boolean; + /** + * Model identifier that generated this tool call + */ + model?: string; + /** + * CAPI interaction ID for correlating this tool execution with upstream telemetry + */ + interactionId?: string; + /** + * Whether this tool call was explicitly requested by the user rather than the assistant + */ isUserRequested?: boolean; + /** + * Tool execution result on success + */ result?: { + /** + * Concise tool result text sent to the LLM for chat completion, potentially truncated for token efficiency + */ content: string; + /** + * Full detailed tool result for UI/timeline display, preserving complete content such as diffs. Falls back to content when absent. + */ detailedContent?: string; + /** + * Structured content blocks (text, images, audio, resources) returned by the tool in their native format + */ contents?: ( | { + /** + * Content block type discriminator + */ type: "text"; + /** + * The text content + */ text: string; } | { + /** + * Content block type discriminator + */ type: "terminal"; + /** + * Terminal/shell output text + */ text: string; + /** + * Process exit code, if the command has completed + */ exitCode?: number; + /** + * Working directory where the command was executed + */ cwd?: string; } | { + /** + * Content block type discriminator + */ type: "image"; + /** + * Base64-encoded image data + */ data: string; + /** + * MIME type of the image (e.g., image/png, image/jpeg) + */ mimeType: string; } | { + /** + * Content block type discriminator + */ type: "audio"; + /** + * Base64-encoded audio data + */ data: string; + /** + * MIME type of the audio (e.g., audio/wav, audio/mpeg) + */ mimeType: string; } | { + /** + * Icons associated with this resource + */ icons?: { + /** + * URL or path to the icon image + */ src: string; + /** + * MIME type of the icon image + */ mimeType?: string; + /** + * Available icon sizes (e.g., ['16x16', '32x32']) + */ sizes?: string[]; + /** + * Theme variant this icon is intended for + */ theme?: "light" | "dark"; }[]; + /** + * Resource name identifier + */ name: string; + /** + * Human-readable display title for the resource + */ title?: string; + /** + * URI identifying the resource + */ uri: string; + /** + * Human-readable description of the resource + */ description?: string; + /** + * MIME type of the resource content + */ mimeType?: string; + /** + * Size of the resource in bytes + */ size?: number; + /** + * Content block type discriminator + */ type: "resource_link"; } | { + /** + * Content block type discriminator + */ type: "resource"; + /** + * The embedded resource contents, either text or base64-encoded binary + */ resource: | { + /** + * URI identifying the resource + */ uri: string; + /** + * MIME type of the text content + */ mimeType?: string; + /** + * Text content of the resource + */ text: string; } | { + /** + * URI identifying the resource + */ uri: string; + /** + * MIME type of the blob content + */ mimeType?: string; + /** + * Base64-encoded binary content of the resource + */ blob: string; }; } )[]; }; + /** + * Error details when the tool execution failed + */ error?: { + /** + * Human-readable error message + */ message: string; + /** + * Machine-readable error code + */ code?: string; }; + /** + * Tool-specific telemetry data (e.g., CodeQL check counts, grep match counts) + */ toolTelemetry?: { [k: string]: unknown; }; + /** + * Tool call ID of the parent tool invocation when this event originates from a sub-agent + */ parentToolCallId?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "skill.invoked"; data: { + /** + * Name of the invoked skill + */ name: string; + /** + * File path to the SKILL.md definition + */ path: string; + /** + * Full content of the skill file, injected into the conversation for the model + */ content: string; + /** + * Tool names that should be auto-approved when this skill is active + */ allowedTools?: string[]; + /** + * Name of the plugin this skill originated from, when applicable + */ + pluginName?: string; + /** + * Version of the plugin this skill originated from, when applicable + */ + pluginVersion?: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "subagent.started"; data: { + /** + * Tool call ID of the parent tool invocation that spawned this sub-agent + */ toolCallId: string; + /** + * Internal name of the sub-agent + */ agentName: string; + /** + * Human-readable display name of the sub-agent + */ agentDisplayName: string; + /** + * Description of what the sub-agent does + */ agentDescription: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "subagent.completed"; data: { + /** + * Tool call ID of the parent tool invocation that spawned this sub-agent + */ toolCallId: string; + /** + * Internal name of the sub-agent + */ agentName: string; + /** + * Human-readable display name of the sub-agent + */ agentDisplayName: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "subagent.failed"; data: { + /** + * Tool call ID of the parent tool invocation that spawned this sub-agent + */ toolCallId: string; + /** + * Internal name of the sub-agent + */ agentName: string; + /** + * Human-readable display name of the sub-agent + */ agentDisplayName: string; + /** + * Error message describing why the sub-agent failed + */ error: string; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "subagent.selected"; data: { + /** + * Internal name of the selected custom agent + */ agentName: string; + /** + * Human-readable display name of the selected custom agent + */ agentDisplayName: string; + /** + * List of tool names available to this agent, or null for all tools + */ tools: string[] | null; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ + ephemeral?: boolean; + type: "subagent.deselected"; + /** + * Empty payload; the event signals that the custom agent was deselected, returning to the default agent + */ + data: {}; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "hook.start"; data: { + /** + * Unique identifier for this hook invocation + */ hookInvocationId: string; + /** + * Type of hook being invoked (e.g., "preToolUse", "postToolUse", "sessionStart") + */ hookType: string; - input?: unknown; + /** + * Input data passed to the hook + */ + input?: { + [k: string]: unknown; + }; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "hook.end"; data: { + /** + * Identifier matching the corresponding hook.start event + */ hookInvocationId: string; + /** + * Type of hook that was invoked (e.g., "preToolUse", "postToolUse", "sessionStart") + */ hookType: string; - output?: unknown; + /** + * Output data produced by the hook + */ + output?: { + [k: string]: unknown; + }; + /** + * Whether the hook completed successfully + */ success: boolean; + /** + * Error details when the hook failed + */ error?: { + /** + * Human-readable error message + */ message: string; + /** + * Error stack trace, when available + */ stack?: string; }; }; } | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ id: string; + /** + * ISO 8601 timestamp when the event was created + */ timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ parentId: string | null; + /** + * When true, the event is transient and not persisted to the session event log on disk + */ ephemeral?: boolean; type: "system.message"; data: { + /** + * The system or developer prompt text + */ content: string; + /** + * Message role: "system" for system prompts, "developer" for developer-injected instructions + */ role: "system" | "developer"; + /** + * Optional name identifier for the message source + */ name?: string; + /** + * Metadata about the prompt template and its construction + */ metadata?: { + /** + * Version identifier of the prompt template used + */ promptVersion?: string; + /** + * Template variables used when constructing the prompt + */ variables?: { [k: string]: unknown; }; }; }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "permission.requested"; + data: { + /** + * Unique identifier for this permission request; used to respond via session.respondToPermission() + */ + requestId: string; + /** + * Details of the permission being requested + */ + permissionRequest: + | { + /** + * Permission kind discriminator + */ + kind: "shell"; + /** + * Tool call ID that triggered this permission request + */ + toolCallId?: string; + /** + * The complete shell command text to be executed + */ + fullCommandText: string; + /** + * Human-readable description of what the command intends to do + */ + intention: string; + /** + * Parsed command identifiers found in the command text + */ + commands: { + /** + * Command identifier (e.g., executable name) + */ + identifier: string; + /** + * Whether this command is read-only (no side effects) + */ + readOnly: boolean; + }[]; + /** + * File paths that may be read or written by the command + */ + possiblePaths: string[]; + /** + * URLs that may be accessed by the command + */ + possibleUrls: { + /** + * URL that may be accessed by the command + */ + url: string; + }[]; + /** + * Whether the command includes a file write redirection (e.g., > or >>) + */ + hasWriteFileRedirection: boolean; + /** + * Whether the UI can offer session-wide approval for this command pattern + */ + canOfferSessionApproval: boolean; + /** + * Optional warning message about risks of running this command + */ + warning?: string; + } + | { + /** + * Permission kind discriminator + */ + kind: "write"; + /** + * Tool call ID that triggered this permission request + */ + toolCallId?: string; + /** + * Human-readable description of the intended file change + */ + intention: string; + /** + * Path of the file being written to + */ + fileName: string; + /** + * Unified diff showing the proposed changes + */ + diff: string; + /** + * Complete new file contents for newly created files + */ + newFileContents?: string; + } + | { + /** + * Permission kind discriminator + */ + kind: "read"; + /** + * Tool call ID that triggered this permission request + */ + toolCallId?: string; + /** + * Human-readable description of why the file is being read + */ + intention: string; + /** + * Path of the file or directory being read + */ + path: string; + } + | { + /** + * Permission kind discriminator + */ + kind: "mcp"; + /** + * Tool call ID that triggered this permission request + */ + toolCallId?: string; + /** + * Name of the MCP server providing the tool + */ + serverName: string; + /** + * Internal name of the MCP tool + */ + toolName: string; + /** + * Human-readable title of the MCP tool + */ + toolTitle: string; + /** + * Arguments to pass to the MCP tool + */ + args?: { + [k: string]: unknown; + }; + /** + * Whether this MCP tool is read-only (no side effects) + */ + readOnly: boolean; + } + | { + /** + * Permission kind discriminator + */ + kind: "url"; + /** + * Tool call ID that triggered this permission request + */ + toolCallId?: string; + /** + * Human-readable description of why the URL is being accessed + */ + intention: string; + /** + * URL to be fetched + */ + url: string; + } + | { + /** + * Permission kind discriminator + */ + kind: "memory"; + /** + * Tool call ID that triggered this permission request + */ + toolCallId?: string; + /** + * Topic or subject of the memory being stored + */ + subject: string; + /** + * The fact or convention being stored + */ + fact: string; + /** + * Source references for the stored fact + */ + citations: string; + } + | { + /** + * Permission kind discriminator + */ + kind: "custom-tool"; + /** + * Tool call ID that triggered this permission request + */ + toolCallId?: string; + /** + * Name of the custom tool + */ + toolName: string; + /** + * Description of what the custom tool does + */ + toolDescription: string; + /** + * Arguments to pass to the custom tool + */ + args?: { + [k: string]: unknown; + }; + }; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "permission.completed"; + data: { + /** + * Request ID of the resolved permission request; clients should dismiss any UI for this request + */ + requestId: string; + /** + * The result of the permission request + */ + result: { + /** + * The outcome of the permission request + */ + kind: + | "approved" + | "denied-by-rules" + | "denied-no-approval-rule-and-could-not-request-from-user" + | "denied-interactively-by-user" + | "denied-by-content-exclusion-policy"; + }; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "user_input.requested"; + data: { + /** + * Unique identifier for this input request; used to respond via session.respondToUserInput() + */ + requestId: string; + /** + * The question or prompt to present to the user + */ + question: string; + /** + * Predefined choices for the user to select from, if applicable + */ + choices?: string[]; + /** + * Whether the user can provide a free-form text response in addition to predefined choices + */ + allowFreeform?: boolean; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "user_input.completed"; + data: { + /** + * Request ID of the resolved user input request; clients should dismiss any UI for this request + */ + requestId: string; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "elicitation.requested"; + data: { + /** + * Unique identifier for this elicitation request; used to respond via session.respondToElicitation() + */ + requestId: string; + /** + * Message describing what information is needed from the user + */ + message: string; + /** + * Elicitation mode; currently only "form" is supported. Defaults to "form" when absent. + */ + mode?: "form"; + /** + * JSON Schema describing the form fields to present to the user + */ + requestedSchema: { + type: "object"; + /** + * Form field definitions, keyed by field name + */ + properties: { + [k: string]: unknown; + }; + /** + * List of required field names + */ + required?: string[]; + }; + [k: string]: unknown; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "elicitation.completed"; + data: { + /** + * Request ID of the resolved elicitation request; clients should dismiss any UI for this request + */ + requestId: string; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "external_tool.requested"; + data: { + /** + * Unique identifier for this request; used to respond via session.respondToExternalTool() + */ + requestId: string; + /** + * Session ID that this external tool request belongs to + */ + sessionId: string; + /** + * Tool call ID assigned to this external tool invocation + */ + toolCallId: string; + /** + * Name of the external tool to invoke + */ + toolName: string; + /** + * Arguments to pass to the external tool + */ + arguments?: { + [k: string]: unknown; + }; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "external_tool.completed"; + data: { + /** + * Request ID of the resolved external tool request; clients should dismiss any UI for this request + */ + requestId: string; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "command.queued"; + data: { + /** + * Unique identifier for this request; used to respond via session.respondToQueuedCommand() + */ + requestId: string; + /** + * The slash command text to be executed (e.g., /help, /clear) + */ + command: string; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "command.completed"; + data: { + /** + * Request ID of the resolved command request; clients should dismiss any UI for this request + */ + requestId: string; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "exit_plan_mode.requested"; + data: { + /** + * Unique identifier for this request; used to respond via session.respondToExitPlanMode() + */ + requestId: string; + /** + * Summary of the plan that was created + */ + summary: string; + /** + * Full content of the plan file + */ + planContent: string; + /** + * Available actions the user can take (e.g., approve, edit, reject) + */ + actions: string[]; + /** + * The recommended action for the user to take + */ + recommendedAction: string; + }; + } + | { + /** + * Unique event identifier (UUID v4), generated when the event is emitted + */ + id: string; + /** + * ISO 8601 timestamp when the event was created + */ + timestamp: string; + /** + * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. + */ + parentId: string | null; + ephemeral: true; + type: "exit_plan_mode.completed"; + data: { + /** + * Request ID of the resolved exit plan mode request; clients should dismiss any UI for this request + */ + requestId: string; + }; }; diff --git a/nodejs/src/sdkProtocolVersion.ts b/nodejs/src/sdkProtocolVersion.ts index 9485bc00d..0e5314374 100644 --- a/nodejs/src/sdkProtocolVersion.ts +++ b/nodejs/src/sdkProtocolVersion.ts @@ -8,7 +8,7 @@ * The SDK protocol version. * This must match the version expected by the copilot-agent-runtime server. */ -export const SDK_PROTOCOL_VERSION = 2; +export const SDK_PROTOCOL_VERSION = 3; /** * Gets the SDK protocol version. diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 04525d2bb..181d1a961 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -8,6 +8,7 @@ */ import type { MessageConnection } from "vscode-jsonrpc/node"; +import { ConnectionError, ResponseError } from "vscode-jsonrpc/node"; import { createSessionRpc } from "./generated/rpc.js"; import type { MessageOptions, @@ -52,7 +53,7 @@ export type AssistantMessageEvent = Extract { + try { + const rawResult = await handler(args, { + sessionId: this.sessionId, + toolCallId, + toolName, + arguments: args, + }); + let result: string; + if (rawResult == null) { + result = ""; + } else if (typeof rawResult === "string") { + result = rawResult; + } else { + result = JSON.stringify(rawResult); + } + await this.rpc.tools.handlePendingToolCall({ requestId, result }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + try { + await this.rpc.tools.handlePendingToolCall({ requestId, error: message }); + } catch (rpcError) { + if (!(rpcError instanceof ConnectionError || rpcError instanceof ResponseError)) { + throw rpcError; + } + // Connection lost or RPC error — nothing we can do + } + } + } + + /** + * Executes a permission handler and sends the result back via RPC. + * @internal + */ + private async _executePermissionAndRespond( + requestId: string, + permissionRequest: PermissionRequest + ): Promise { + try { + const result = await this.permissionHandler!(permissionRequest, { + sessionId: this.sessionId, + }); + await this.rpc.permissions.handlePendingPermissionRequest({ requestId, result }); + } catch (_error) { + try { + await this.rpc.permissions.handlePendingPermissionRequest({ + requestId, + result: { + kind: "denied-no-approval-rule-and-could-not-request-from-user", + }, + }); + } catch (rpcError) { + if (!(rpcError instanceof ConnectionError || rpcError instanceof ResponseError)) { + throw rpcError; + } + // Connection lost or RPC error — nothing we can do + } + } + } + /** * Registers custom tool handlers for this session. * @@ -382,15 +489,15 @@ export class CopilotSession { } /** - * Handles a permission request from the Copilot CLI. + * Handles a permission request in the v2 protocol format (synchronous RPC). + * Used as a back-compat adapter when connected to a v2 server. * * @param request - The permission request data from the CLI * @returns A promise that resolves with the permission decision * @internal This method is for internal use by the SDK. */ - async _handlePermissionRequest(request: unknown): Promise { + async _handlePermissionRequestV2(request: unknown): Promise { if (!this.permissionHandler) { - // No handler registered, deny permission return { kind: "denied-no-approval-rule-and-could-not-request-from-user" }; } @@ -400,7 +507,6 @@ export class CopilotSession { }); return result; } catch (_error) { - // Handler failed, deny permission return { kind: "denied-no-approval-rule-and-could-not-request-from-user" }; } } @@ -478,7 +584,7 @@ export class CopilotSession { * assistant responses, tool executions, and other session events. * * @returns A promise that resolves with an array of all session events - * @throws Error if the session has been destroyed or the connection fails + * @throws Error if the session has been disconnected or the connection fails * * @example * ```typescript @@ -499,22 +605,27 @@ export class CopilotSession { } /** - * Destroys this session and releases all associated resources. + * Disconnects this session and releases all in-memory resources (event handlers, + * tool handlers, permission handlers). * - * After calling this method, the session can no longer be used. All event - * handlers and tool handlers are cleared. To continue the conversation, - * use {@link CopilotClient.resumeSession} with the session ID. + * Session state on disk (conversation history, planning state, artifacts) is + * preserved, so the conversation can be resumed later by calling + * {@link CopilotClient.resumeSession} with the session ID. To permanently + * remove all session data including files on disk, use + * {@link CopilotClient.deleteSession} instead. * - * @returns A promise that resolves when the session is destroyed + * After calling this method, the session object can no longer be used. + * + * @returns A promise that resolves when the session is disconnected * @throws Error if the connection fails * * @example * ```typescript - * // Clean up when done - * await session.destroy(); + * // Clean up when done — session can still be resumed later + * await session.disconnect(); * ``` */ - async destroy(): Promise { + async disconnect(): Promise { await this.connection.sendRequest("session.destroy", { sessionId: this.sessionId, }); @@ -524,6 +635,24 @@ export class CopilotSession { this.permissionHandler = undefined; } + /** + * @deprecated Use {@link disconnect} instead. This method will be removed in a future release. + * + * Disconnects this session and releases all in-memory resources. + * Session data on disk is preserved for later resumption. + * + * @returns A promise that resolves when the session is disconnected + * @throws Error if the connection fails + */ + async destroy(): Promise { + return this.disconnect(); + } + + /** Enables `await using session = ...` syntax for automatic cleanup. */ + async [Symbol.asyncDispose](): Promise { + return this.disconnect(); + } + /** * Aborts the currently processing message in this session. * @@ -531,7 +660,7 @@ export class CopilotSession { * and can continue to be used for new messages. * * @returns A promise that resolves when the abort request is acknowledged - * @throws Error if the session has been destroyed or the connection fails + * @throws Error if the session has been disconnected or the connection fails * * @example * ```typescript @@ -549,4 +678,19 @@ export class CopilotSession { sessionId: this.sessionId, }); } + + /** + * Change the model for this session. + * The new model takes effect for the next message. Conversation history is preserved. + * + * @param model - Model ID to switch to + * + * @example + * ```typescript + * await session.setModel("gpt-4.1"); + * ``` + */ + async setModel(model: string): Promise { + await this.rpc.model.switchTo({ modelId: model }); + } } diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 6ed46969f..b2b37f90a 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -44,6 +44,13 @@ export interface CopilotClientOptions { */ useStdio?: boolean; + /** + * When true, indicates the SDK is running as a child process of the Copilot CLI server, and should + * use its own stdio for communicating with the existing parent process. Can only be used in combination + * with useStdio: true. + */ + isChildProcess?: boolean; + /** * URL of an existing Copilot CLI server to connect to over TCP * When provided, the client will not spawn a CLI process @@ -154,6 +161,12 @@ export interface Tool { description?: string; parameters?: ZodSchema | Record; handler: ToolHandler; + /** + * When true, explicitly indicates this tool is intended to override a built-in tool + * of the same name. If not set and the name clashes with a built-in tool, the runtime + * will return an error. + */ + overridesBuiltInTool?: boolean; } /** @@ -166,6 +179,7 @@ export function defineTool( description?: string; parameters?: ZodSchema | Record; handler: ToolHandler; + overridesBuiltInTool?: boolean; } ): Tool { return { name, ...config }; @@ -219,19 +233,15 @@ export type SystemMessageConfig = SystemMessageAppendConfig | SystemMessageRepla * Permission request types from the server */ export interface PermissionRequest { - kind: "shell" | "write" | "mcp" | "read" | "url"; + kind: "shell" | "write" | "mcp" | "read" | "url" | "custom-tool"; toolCallId?: string; [key: string]: unknown; } -export interface PermissionRequestResult { - kind: - | "approved" - | "denied-by-rules" - | "denied-no-approval-rule-and-could-not-request-from-user" - | "denied-interactively-by-user"; - rules?: unknown[]; -} +import type { SessionPermissionsHandlePendingPermissionRequestParams } from "./generated/rpc.js"; + +export type PermissionRequestResult = + SessionPermissionsHandlePendingPermissionRequestParams["result"]; export type PermissionHandler = ( request: PermissionRequest, @@ -683,7 +693,7 @@ export interface SessionConfig { * Handler for permission requests from the server. * When provided, the server will call this handler to request permission for operations. */ - onPermissionRequest?: PermissionHandler; + onPermissionRequest: PermissionHandler; /** * Handler for user input requests from the agent. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 1ab89e7c2..c18428a84 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,30 +1,29 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, expect, it, onTestFinished, vi } from "vitest"; -import { CopilotClient } from "../src/index.js"; +import { approveAll, CopilotClient } from "../src/index.js"; // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead describe("CopilotClient", () => { - it("returns a standardized failure result when a tool is not registered", async () => { + it("throws when createSession is called without onPermissionRequest", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); - const session = await client.createSession(); + await expect((client as any).createSession({})).rejects.toThrow( + /onPermissionRequest.*is required/ + ); + }); - const response = await ( - client as unknown as { handleToolCallRequest: (typeof client)["handleToolCallRequest"] } - ).handleToolCallRequest({ - sessionId: session.sessionId, - toolCallId: "123", - toolName: "missing_tool", - arguments: {}, - }); + it("throws when resumeSession is called without onPermissionRequest", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); - expect(response.result).toMatchObject({ - resultType: "failure", - error: "tool 'missing_tool' not supported", - }); + const session = await client.createSession({ onPermissionRequest: approveAll }); + await expect((client as any).resumeSession(session.sessionId, {})).rejects.toThrow( + /onPermissionRequest.*is required/ + ); }); it("forwards clientName in session.create request", async () => { @@ -33,7 +32,7 @@ describe("CopilotClient", () => { onTestFinished(() => client.forceStop()); const spy = vi.spyOn((client as any).connection!, "sendRequest"); - await client.createSession({ clientName: "my-app" }); + await client.createSession({ clientName: "my-app", onPermissionRequest: approveAll }); expect(spy).toHaveBeenCalledWith( "session.create", @@ -46,14 +45,50 @@ describe("CopilotClient", () => { await client.start(); onTestFinished(() => client.forceStop()); - const session = await client.createSession(); - const spy = vi.spyOn((client as any).connection!, "sendRequest"); - await client.resumeSession(session.sessionId, { clientName: "my-app" }); + const session = await client.createSession({ onPermissionRequest: approveAll }); + // Mock sendRequest to capture the call without hitting the runtime + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.resumeSession(session.sessionId, { + clientName: "my-app", + onPermissionRequest: approveAll, + }); expect(spy).toHaveBeenCalledWith( "session.resume", expect.objectContaining({ clientName: "my-app", sessionId: session.sessionId }) ); + spy.mockRestore(); + }); + + it("sends session.model.switchTo RPC with correct params", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + + // Mock sendRequest to capture the call without hitting the runtime + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, _params: any) => { + if (method === "session.model.switchTo") return {}; + // Fall through for other methods (shouldn't be called) + throw new Error(`Unexpected method: ${method}`); + }); + + await session.setModel("gpt-4.1"); + + expect(spy).toHaveBeenCalledWith("session.model.switchTo", { + sessionId: session.sessionId, + modelId: "gpt-4.1", + }); + + spy.mockRestore(); }); describe("URL parsing", () => { @@ -271,4 +306,62 @@ describe("CopilotClient", () => { }).toThrow(/githubToken and useLoggedInUser cannot be used with cliUrl/); }); }); + + describe("overridesBuiltInTool in tool definitions", () => { + it("sends overridesBuiltInTool in tool definition on session.create", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi.spyOn((client as any).connection!, "sendRequest"); + await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + { + name: "grep", + description: "custom grep", + handler: async () => "ok", + overridesBuiltInTool: true, + }, + ], + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.create")![1] as any; + expect(payload.tools).toEqual([ + expect.objectContaining({ name: "grep", overridesBuiltInTool: true }), + ]); + }); + + it("sends overridesBuiltInTool in tool definition on session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + // Mock sendRequest to capture the call without hitting the runtime + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + tools: [ + { + name: "grep", + description: "custom grep", + handler: async () => "ok", + overridesBuiltInTool: true, + }, + ], + }); + + const payload = spy.mock.calls.find((c) => c[0] === "session.resume")![1] as any; + expect(payload.tools).toEqual([ + expect.objectContaining({ name: "grep", overridesBuiltInTool: true }), + ]); + spy.mockRestore(); + }); + }); }); diff --git a/nodejs/test/e2e/agent_and_compact_rpc.test.ts b/nodejs/test/e2e/agent_and_compact_rpc.test.ts new file mode 100644 index 000000000..336cd69b6 --- /dev/null +++ b/nodejs/test/e2e/agent_and_compact_rpc.test.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import type { CustomAgentConfig } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("Agent Selection RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should list available custom agents", async () => { + const customAgents: CustomAgentConfig[] = [ + { + name: "test-agent", + displayName: "Test Agent", + description: "A test agent", + prompt: "You are a test agent.", + }, + { + name: "another-agent", + displayName: "Another Agent", + description: "Another test agent", + prompt: "You are another agent.", + }, + ]; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + customAgents, + }); + + const result = await session.rpc.agent.list(); + expect(result.agents).toBeDefined(); + expect(Array.isArray(result.agents)).toBe(true); + expect(result.agents.length).toBe(2); + expect(result.agents[0].name).toBe("test-agent"); + expect(result.agents[0].displayName).toBe("Test Agent"); + expect(result.agents[0].description).toBe("A test agent"); + expect(result.agents[1].name).toBe("another-agent"); + + await session.disconnect(); + }); + + it("should return null when no agent is selected", async () => { + const customAgents: CustomAgentConfig[] = [ + { + name: "test-agent", + displayName: "Test Agent", + description: "A test agent", + prompt: "You are a test agent.", + }, + ]; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + customAgents, + }); + + const result = await session.rpc.agent.getCurrent(); + expect(result.agent).toBeNull(); + + await session.disconnect(); + }); + + it("should select and get current agent", async () => { + const customAgents: CustomAgentConfig[] = [ + { + name: "test-agent", + displayName: "Test Agent", + description: "A test agent", + prompt: "You are a test agent.", + }, + ]; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + customAgents, + }); + + // Select the agent + const selectResult = await session.rpc.agent.select({ name: "test-agent" }); + expect(selectResult.agent).toBeDefined(); + expect(selectResult.agent.name).toBe("test-agent"); + expect(selectResult.agent.displayName).toBe("Test Agent"); + + // Verify getCurrent returns the selected agent + const currentResult = await session.rpc.agent.getCurrent(); + expect(currentResult.agent).not.toBeNull(); + expect(currentResult.agent!.name).toBe("test-agent"); + + await session.disconnect(); + }); + + it("should deselect current agent", async () => { + const customAgents: CustomAgentConfig[] = [ + { + name: "test-agent", + displayName: "Test Agent", + description: "A test agent", + prompt: "You are a test agent.", + }, + ]; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + customAgents, + }); + + // Select then deselect + await session.rpc.agent.select({ name: "test-agent" }); + await session.rpc.agent.deselect(); + + // Verify no agent is selected + const currentResult = await session.rpc.agent.getCurrent(); + expect(currentResult.agent).toBeNull(); + + await session.disconnect(); + }); + + it("should return empty list when no custom agents configured", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + const result = await session.rpc.agent.list(); + expect(result.agents).toEqual([]); + + await session.disconnect(); + }); +}); + +describe("Session Compact RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should compact session history after messages", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + // Send a message to create some history + await session.sendAndWait({ prompt: "What is 2+2?" }); + + // Compact the session + const result = await session.rpc.compaction.compact(); + expect(typeof result.success).toBe("boolean"); + expect(typeof result.tokensRemoved).toBe("number"); + expect(typeof result.messagesRemoved).toBe("number"); + + await session.disconnect(); + }, 60000); +}); diff --git a/nodejs/test/e2e/ask_user.test.ts b/nodejs/test/e2e/ask_user.test.ts index d6c89a249..deb0d788c 100644 --- a/nodejs/test/e2e/ask_user.test.ts +++ b/nodejs/test/e2e/ask_user.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import type { UserInputRequest, UserInputResponse } from "../../src/index.js"; +import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("User input (ask_user)", async () => { @@ -13,6 +14,7 @@ describe("User input (ask_user)", async () => { const userInputRequests: UserInputRequest[] = []; const session = await client.createSession({ + onPermissionRequest: approveAll, onUserInputRequest: async (request, invocation) => { userInputRequests.push(request); expect(invocation.sessionId).toBe(session.sessionId); @@ -36,13 +38,14 @@ describe("User input (ask_user)", async () => { // The request should have a question expect(userInputRequests.some((req) => req.question && req.question.length > 0)).toBe(true); - await session.destroy(); + await session.disconnect(); }); it("should receive choices in user input request", async () => { const userInputRequests: UserInputRequest[] = []; const session = await client.createSession({ + onPermissionRequest: approveAll, onUserInputRequest: async (request) => { userInputRequests.push(request); // Pick the first choice @@ -66,7 +69,7 @@ describe("User input (ask_user)", async () => { ); expect(requestWithChoices).toBeDefined(); - await session.destroy(); + await session.disconnect(); }); it("should handle freeform user input response", async () => { @@ -74,6 +77,7 @@ describe("User input (ask_user)", async () => { const freeformAnswer = "This is my custom freeform answer that was not in the choices"; const session = await client.createSession({ + onPermissionRequest: approveAll, onUserInputRequest: async (request) => { userInputRequests.push(request); // Return a freeform answer (not from choices) @@ -95,6 +99,6 @@ describe("User input (ask_user)", async () => { // (This is a soft check since the model may paraphrase) expect(response).toBeDefined(); - await session.destroy(); + await session.disconnect(); }); }); diff --git a/nodejs/test/e2e/builtin_tools.test.ts b/nodejs/test/e2e/builtin_tools.test.ts new file mode 100644 index 000000000..127dae588 --- /dev/null +++ b/nodejs/test/e2e/builtin_tools.test.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { writeFile, mkdir } from "fs/promises"; +import { join } from "path"; +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext"; + +describe("Built-in Tools", async () => { + const { copilotClient: client, workDir } = await createSdkTestContext(); + + describe("bash", () => { + it("should capture exit code in output", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + const msg = await session.sendAndWait({ + prompt: "Run 'echo hello && echo world'. Tell me the exact output.", + }); + expect(msg?.data.content).toContain("hello"); + expect(msg?.data.content).toContain("world"); + }); + + it.skipIf(process.platform === "win32")("should capture stderr output", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + const msg = await session.sendAndWait({ + prompt: "Run 'echo error_msg >&2; echo ok' and tell me what stderr said. Reply with just the stderr content.", + }); + expect(msg?.data.content).toContain("error_msg"); + }); + }); + + describe("view", () => { + it("should read file with line range", async () => { + await writeFile(join(workDir, "lines.txt"), "line1\nline2\nline3\nline4\nline5\n"); + const session = await client.createSession({ onPermissionRequest: approveAll }); + const msg = await session.sendAndWait({ + prompt: "Read lines 2 through 4 of the file 'lines.txt' in this directory. Tell me what those lines contain.", + }); + expect(msg?.data.content).toContain("line2"); + expect(msg?.data.content).toContain("line4"); + }); + + it("should handle nonexistent file gracefully", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + const msg = await session.sendAndWait({ + prompt: "Try to read the file 'does_not_exist.txt'. If it doesn't exist, say 'FILE_NOT_FOUND'.", + }); + expect(msg?.data.content?.toUpperCase()).toMatch( + /NOT.FOUND|NOT.EXIST|NO.SUCH|FILE_NOT_FOUND|DOES.NOT.EXIST|ERROR/i + ); + }); + }); + + describe("edit", () => { + it("should edit a file successfully", async () => { + await writeFile(join(workDir, "edit_me.txt"), "Hello World\nGoodbye World\n"); + const session = await client.createSession({ onPermissionRequest: approveAll }); + const msg = await session.sendAndWait({ + prompt: "Edit the file 'edit_me.txt': replace 'Hello World' with 'Hi Universe'. Then read it back and tell me its contents.", + }); + expect(msg?.data.content).toContain("Hi Universe"); + }); + }); + + describe("create_file", () => { + it("should create a new file", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + const msg = await session.sendAndWait({ + prompt: "Create a file called 'new_file.txt' with the content 'Created by test'. Then read it back to confirm.", + }); + expect(msg?.data.content).toContain("Created by test"); + }); + }); + + describe("grep", () => { + it("should search for patterns in files", async () => { + await writeFile(join(workDir, "data.txt"), "apple\nbanana\napricot\ncherry\n"); + const session = await client.createSession({ onPermissionRequest: approveAll }); + const msg = await session.sendAndWait({ + prompt: "Search for lines starting with 'ap' in the file 'data.txt'. Tell me which lines matched.", + }); + expect(msg?.data.content).toContain("apple"); + expect(msg?.data.content).toContain("apricot"); + }); + }); + + describe("glob", () => { + it("should find files by pattern", async () => { + await mkdir(join(workDir, "src"), { recursive: true }); + await writeFile(join(workDir, "src", "index.ts"), "export const index = 1;"); + await writeFile(join(workDir, "README.md"), "# Readme"); + const session = await client.createSession({ onPermissionRequest: approveAll }); + const msg = await session.sendAndWait({ + prompt: "Find all .ts files in this directory (recursively). List the filenames you found.", + }); + expect(msg?.data.content).toContain("index.ts"); + }); + }); +}); diff --git a/nodejs/test/e2e/client.test.ts b/nodejs/test/e2e/client.test.ts index aa8ddcbd6..9d71ee726 100644 --- a/nodejs/test/e2e/client.test.ts +++ b/nodejs/test/e2e/client.test.ts @@ -1,6 +1,6 @@ import { ChildProcess } from "child_process"; import { describe, expect, it, onTestFinished } from "vitest"; -import { CopilotClient } from "../../src/index.js"; +import { CopilotClient, approveAll } from "../../src/index.js"; function onTestFinishedForceStop(client: CopilotClient) { onTestFinished(async () => { @@ -51,9 +51,9 @@ describe("Client", () => { // the process has exited. const client = new CopilotClient({ useStdio: false }); - await client.createSession(); + await client.createSession({ onPermissionRequest: approveAll }); - // Kill the server process to force cleanup to fail + // Kill the server processto force cleanup to fail // eslint-disable-next-line @typescript-eslint/no-explicit-any const cliProcess = (client as any).cliProcess as ChildProcess; expect(cliProcess).toBeDefined(); @@ -62,14 +62,14 @@ describe("Client", () => { const errors = await client.stop(); expect(errors.length).toBeGreaterThan(0); - expect(errors[0].message).toContain("Failed to destroy session"); + expect(errors[0].message).toContain("Failed to disconnect session"); }); it("should forceStop without cleanup", async () => { const client = new CopilotClient({}); onTestFinishedForceStop(client); - await client.createSession(); + await client.createSession({ onPermissionRequest: approveAll }); await client.forceStop(); expect(client.getState()).toBe("disconnected"); }); @@ -152,7 +152,7 @@ describe("Client", () => { // Verify subsequent calls also fail (don't hang) try { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); await session.send("test"); expect.fail("Expected send() to throw an error after CLI exit"); } catch (error) { diff --git a/nodejs/test/e2e/client_lifecycle.test.ts b/nodejs/test/e2e/client_lifecycle.test.ts new file mode 100644 index 000000000..5b7bc3d81 --- /dev/null +++ b/nodejs/test/e2e/client_lifecycle.test.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { SessionLifecycleEvent, approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext"; + +describe("Client Lifecycle", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should return last session id after sending a message", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + await session.sendAndWait({ prompt: "Say hello" }); + + // Wait for session data to flush to disk + await new Promise((r) => setTimeout(r, 500)); + + // In parallel test runs we can't guarantee the last session ID matches + // this specific session, since other tests may flush session data concurrently. + const lastSessionId = await client.getLastSessionId(); + expect(lastSessionId).toBeTruthy(); + + await session.disconnect(); + }); + + it("should return undefined for getLastSessionId with no sessions", async () => { + // On a fresh client this may return undefined or an older session ID + const lastSessionId = await client.getLastSessionId(); + expect(lastSessionId === undefined || typeof lastSessionId === "string").toBe(true); + }); + + it("should emit session lifecycle events", async () => { + const events: SessionLifecycleEvent[] = []; + const unsubscribe = client.on((event: SessionLifecycleEvent) => { + events.push(event); + }); + + try { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + await session.sendAndWait({ prompt: "Say hello" }); + + // Wait for session data to flush to disk + await new Promise((r) => setTimeout(r, 500)); + + // Lifecycle events may not fire in all runtimes + if (events.length > 0) { + const sessionEvents = events.filter((e) => e.sessionId === session.sessionId); + expect(sessionEvents.length).toBeGreaterThan(0); + } + + await session.disconnect(); + } finally { + unsubscribe(); + } + }); +}); diff --git a/nodejs/test/e2e/compaction.test.ts b/nodejs/test/e2e/compaction.test.ts index 820b72ffb..02e14470f 100644 --- a/nodejs/test/e2e/compaction.test.ts +++ b/nodejs/test/e2e/compaction.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from "vitest"; -import { SessionEvent } from "../../src/index.js"; +import { SessionEvent, approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; -describe("Compaction", async () => { +// TODO: Compaction tests are skipped due to flakiness — re-enable once stabilized +describe.skip("Compaction", async () => { const { copilotClient: client } = await createSdkTestContext(); it("should trigger compaction with low threshold and emit events", async () => { // Create session with very low compaction thresholds to trigger compaction quickly const session = await client.createSession({ + onPermissionRequest: approveAll, infiniteSessions: { enabled: true, // Trigger background compaction at 0.5% context usage (~1000 tokens) @@ -25,7 +27,7 @@ describe("Compaction", async () => { // Send multiple messages to fill up the context window // With such low thresholds, even a few messages should trigger compaction await session.sendAndWait({ - prompt: "Tell me a long story about a dragon. Be very detailed.", + prompt: "Tell me a story about a dragon. Be detailed.", }); await session.sendAndWait({ prompt: "Continue the story with more details about the dragon's castle.", @@ -63,6 +65,7 @@ describe("Compaction", async () => { it("should not emit compaction events when infinite sessions disabled", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, infiniteSessions: { enabled: false, }, diff --git a/nodejs/test/e2e/error_resilience.test.ts b/nodejs/test/e2e/error_resilience.test.ts new file mode 100644 index 000000000..183ea1188 --- /dev/null +++ b/nodejs/test/e2e/error_resilience.test.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext"; + +describe("Error Resilience", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should throw when sending to disconnected session", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + await session.disconnect(); + + await expect(session.sendAndWait({ prompt: "Hello" })).rejects.toThrow(); + }); + + it("should throw when getting messages from disconnected session", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + await session.disconnect(); + + await expect(session.getMessages()).rejects.toThrow(); + }); + + it("should handle double abort without error", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + // First abort should be fine + await session.abort(); + // Second abort should not throw + await session.abort(); + + // Session should still be disconnectable + await session.disconnect(); + }); + + it("should throw when resuming non-existent session", async () => { + await expect( + client.resumeSession("non-existent-session-id-12345", { + onPermissionRequest: approveAll, + }) + ).rejects.toThrow(); + }); +}); diff --git a/nodejs/test/e2e/event_fidelity.test.ts b/nodejs/test/e2e/event_fidelity.test.ts new file mode 100644 index 000000000..7cd65b6fc --- /dev/null +++ b/nodejs/test/e2e/event_fidelity.test.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { writeFile } from "fs/promises"; +import { join } from "path"; +import { describe, expect, it } from "vitest"; +import { SessionEvent, approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext"; + +describe("Event Fidelity", async () => { + const { copilotClient: client, workDir } = await createSdkTestContext(); + + it("should emit events in correct order for tool-using conversation", async () => { + await writeFile(join(workDir, "hello.txt"), "Hello World"); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const events: SessionEvent[] = []; + session.on((event) => { + events.push(event); + }); + + await session.sendAndWait({ + prompt: "Read the file 'hello.txt' and tell me its contents.", + }); + + const types = events.map((e) => e.type); + + // Must have user message, tool execution, assistant message, and idle + expect(types).toContain("user.message"); + expect(types).toContain("assistant.message"); + + // user.message should come before assistant.message + const userIdx = types.indexOf("user.message"); + const assistantIdx = types.lastIndexOf("assistant.message"); + expect(userIdx).toBeLessThan(assistantIdx); + + // session.idle should be last + const idleIdx = types.lastIndexOf("session.idle"); + expect(idleIdx).toBe(types.length - 1); + + await session.disconnect(); + }); + + it("should include valid fields on all events", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + const events: SessionEvent[] = []; + session.on((event) => { + events.push(event); + }); + + await session.sendAndWait({ + prompt: "What is 5+5? Reply with just the number.", + }); + + // All events must have id and timestamp + for (const event of events) { + expect(event.id).toBeDefined(); + expect(typeof event.id).toBe("string"); + expect(event.id.length).toBeGreaterThan(0); + + expect(event.timestamp).toBeDefined(); + expect(typeof event.timestamp).toBe("string"); + } + + // user.message should have content + const userEvent = events.find((e) => e.type === "user.message"); + expect(userEvent).toBeDefined(); + expect(userEvent?.data.content).toBeDefined(); + + // assistant.message should have messageId and content + const assistantEvent = events.find((e) => e.type === "assistant.message"); + expect(assistantEvent).toBeDefined(); + expect(assistantEvent?.data.messageId).toBeDefined(); + expect(assistantEvent?.data.content).toBeDefined(); + + await session.disconnect(); + }); + + it("should emit tool execution events with correct fields", async () => { + await writeFile(join(workDir, "data.txt"), "test data"); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + const events: SessionEvent[] = []; + session.on((event) => { + events.push(event); + }); + + await session.sendAndWait({ + prompt: "Read the file 'data.txt'.", + }); + + // Should have tool.execution_start and tool.execution_complete + const toolStarts = events.filter((e) => e.type === "tool.execution_start"); + const toolCompletes = events.filter((e) => e.type === "tool.execution_complete"); + + expect(toolStarts.length).toBeGreaterThanOrEqual(1); + expect(toolCompletes.length).toBeGreaterThanOrEqual(1); + + // Tool start should have toolCallId and toolName + const firstStart = toolStarts[0]!; + expect(firstStart.data.toolCallId).toBeDefined(); + expect(firstStart.data.toolName).toBeDefined(); + + // Tool complete should have toolCallId + const firstComplete = toolCompletes[0]!; + expect(firstComplete.data.toolCallId).toBeDefined(); + + await session.disconnect(); + }); + + it("should emit assistant.message with messageId", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + const events: SessionEvent[] = []; + session.on((event) => { + events.push(event); + }); + + await session.sendAndWait({ + prompt: "Say 'pong'.", + }); + + const assistantEvents = events.filter((e) => e.type === "assistant.message"); + expect(assistantEvents.length).toBeGreaterThanOrEqual(1); + + // messageId should be present + const msg = assistantEvents[0]!; + expect(msg.data.messageId).toBeDefined(); + expect(typeof msg.data.messageId).toBe("string"); + expect(msg.data.content).toContain("pong"); + + await session.disconnect(); + }); +}); diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index 4986d1299..ed505a0cb 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -13,13 +13,20 @@ import { CopilotClient } from "../../../src"; import { CapiProxy } from "./CapiProxy"; import { retry } from "./sdkTestHelper"; +export const isCI = process.env.GITHUB_ACTIONS === "true"; + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const SNAPSHOTS_DIR = resolve(__dirname, "../../../../test/snapshots"); export async function createSdkTestContext({ logLevel, -}: { logLevel?: "error" | "none" | "warning" | "info" | "debug" | "all"; cliPath?: string } = {}) { + useStdio, +}: { + logLevel?: "error" | "none" | "warning" | "info" | "debug" | "all"; + cliPath?: string; + useStdio?: boolean; +} = {}) { const homeDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-test-config-"))); const workDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-test-work-"))); @@ -42,7 +49,8 @@ export async function createSdkTestContext({ logLevel: logLevel || "error", cliPath: process.env.COPILOT_CLI_PATH, // Use fake token in CI to allow cached responses without real auth - githubToken: process.env.CI === "true" ? "fake-token-for-e2e-tests" : undefined, + githubToken: isCI ? "fake-token-for-e2e-tests" : undefined, + useStdio: useStdio, }); const harness = { homeDir, workDir, openAiEndpoint, copilotClient, env }; diff --git a/nodejs/test/e2e/hooks.test.ts b/nodejs/test/e2e/hooks.test.ts index 18cc9fea0..9743d91f3 100644 --- a/nodejs/test/e2e/hooks.test.ts +++ b/nodejs/test/e2e/hooks.test.ts @@ -45,7 +45,7 @@ describe("Session hooks", async () => { // Should have received the tool name expect(preToolUseInputs.some((input) => input.toolName)).toBe(true); - await session.destroy(); + await session.disconnect(); }); it("should invoke postToolUse hook after model runs a tool", async () => { @@ -76,7 +76,7 @@ describe("Session hooks", async () => { expect(postToolUseInputs.some((input) => input.toolName)).toBe(true); expect(postToolUseInputs.some((input) => input.toolResult !== undefined)).toBe(true); - await session.destroy(); + await session.disconnect(); }); it("should invoke both preToolUse and postToolUse hooks for a single tool call", async () => { @@ -113,13 +113,14 @@ describe("Session hooks", async () => { const commonTool = preToolNames.find((name) => postToolNames.includes(name)); expect(commonTool).toBeDefined(); - await session.destroy(); + await session.disconnect(); }); it("should deny tool execution when preToolUse returns deny", async () => { const preToolUseInputs: PreToolUseHookInput[] = []; const session = await client.createSession({ + onPermissionRequest: approveAll, hooks: { onPreToolUse: async (input) => { preToolUseInputs.push(input); @@ -144,6 +145,6 @@ describe("Session hooks", async () => { // At minimum, we verify the hook was invoked expect(response).toBeDefined(); - await session.destroy(); + await session.disconnect(); }); }); diff --git a/nodejs/test/e2e/hooks_extended.test.ts b/nodejs/test/e2e/hooks_extended.test.ts new file mode 100644 index 000000000..9b12c4418 --- /dev/null +++ b/nodejs/test/e2e/hooks_extended.test.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import type { + ErrorOccurredHookInput, + SessionEndHookInput, + SessionStartHookInput, + UserPromptSubmittedHookInput, +} from "../../src/types.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("Extended session hooks", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should invoke onSessionStart hook on new session", async () => { + const sessionStartInputs: SessionStartHookInput[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onSessionStart: async (input, invocation) => { + sessionStartInputs.push(input); + expect(invocation.sessionId).toBe(session.sessionId); + }, + }, + }); + + await session.sendAndWait({ + prompt: "Say hi", + }); + + expect(sessionStartInputs.length).toBeGreaterThan(0); + expect(sessionStartInputs[0].source).toBe("new"); + expect(sessionStartInputs[0].timestamp).toBeGreaterThan(0); + expect(sessionStartInputs[0].cwd).toBeDefined(); + + await session.disconnect(); + }); + + it("should invoke onUserPromptSubmitted hook when sending a message", async () => { + const userPromptInputs: UserPromptSubmittedHookInput[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onUserPromptSubmitted: async (input, invocation) => { + userPromptInputs.push(input); + expect(invocation.sessionId).toBe(session.sessionId); + }, + }, + }); + + await session.sendAndWait({ + prompt: "Say hello", + }); + + expect(userPromptInputs.length).toBeGreaterThan(0); + expect(userPromptInputs[0].prompt).toContain("Say hello"); + expect(userPromptInputs[0].timestamp).toBeGreaterThan(0); + expect(userPromptInputs[0].cwd).toBeDefined(); + + await session.disconnect(); + }); + + it("should invoke onSessionEnd hook when session is disconnected", async () => { + const sessionEndInputs: SessionEndHookInput[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onSessionEnd: async (input, invocation) => { + sessionEndInputs.push(input); + expect(invocation.sessionId).toBe(session.sessionId); + }, + }, + }); + + await session.sendAndWait({ + prompt: "Say hi", + }); + + await session.disconnect(); + + // Wait briefly for async hook + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(sessionEndInputs.length).toBeGreaterThan(0); + }); + + it("should invoke onErrorOccurred hook when error occurs", async () => { + const errorInputs: ErrorOccurredHookInput[] = []; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onErrorOccurred: async (input, invocation) => { + errorInputs.push(input); + expect(invocation.sessionId).toBe(session.sessionId); + expect(input.timestamp).toBeGreaterThan(0); + expect(input.cwd).toBeDefined(); + expect(input.error).toBeDefined(); + expect(["model_call", "tool_execution", "system", "user_input"]).toContain( + input.errorContext + ); + expect(typeof input.recoverable).toBe("boolean"); + }, + }, + }); + + await session.sendAndWait({ + prompt: "Say hi", + }); + + // onErrorOccurred is dispatched by the runtime for actual errors (model failures, system errors). + // In a normal session it may not fire. Verify the hook is properly wired by checking + // that the session works correctly with the hook registered. + // If the hook did fire, the assertions inside it would have run. + expect(session.sessionId).toBeDefined(); + + await session.disconnect(); + }); +}); diff --git a/nodejs/test/e2e/mcp_and_agents.test.ts b/nodejs/test/e2e/mcp_and_agents.test.ts index 7b7aabf06..28ebf28b5 100644 --- a/nodejs/test/e2e/mcp_and_agents.test.ts +++ b/nodejs/test/e2e/mcp_and_agents.test.ts @@ -28,6 +28,7 @@ describe("MCP Servers and Custom Agents", async () => { }; const session = await client.createSession({ + onPermissionRequest: approveAll, mcpServers, }); @@ -39,12 +40,12 @@ describe("MCP Servers and Custom Agents", async () => { }); expect(message?.data.content).toContain("4"); - await session.destroy(); + await session.disconnect(); }); it("should accept MCP server configuration on session resume", async () => { // Create a session first - const session1 = await client.createSession(); + const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; await session1.sendAndWait({ prompt: "What is 1+1?" }); @@ -59,6 +60,7 @@ describe("MCP Servers and Custom Agents", async () => { }; const session2 = await client.resumeSession(sessionId, { + onPermissionRequest: approveAll, mcpServers, }); @@ -69,7 +71,7 @@ describe("MCP Servers and Custom Agents", async () => { }); expect(message?.data.content).toContain("6"); - await session2.destroy(); + await session2.disconnect(); }); it("should handle multiple MCP servers", async () => { @@ -89,11 +91,12 @@ describe("MCP Servers and Custom Agents", async () => { }; const session = await client.createSession({ + onPermissionRequest: approveAll, mcpServers, }); expect(session.sessionId).toBeDefined(); - await session.destroy(); + await session.disconnect(); }); it("should pass literal env values to MCP server subprocess", async () => { @@ -119,7 +122,7 @@ describe("MCP Servers and Custom Agents", async () => { }); expect(message?.data.content).toContain("hunter2"); - await session.destroy(); + await session.disconnect(); }); }); @@ -136,6 +139,7 @@ describe("MCP Servers and Custom Agents", async () => { ]; const session = await client.createSession({ + onPermissionRequest: approveAll, customAgents, }); @@ -147,12 +151,12 @@ describe("MCP Servers and Custom Agents", async () => { }); expect(message?.data.content).toContain("10"); - await session.destroy(); + await session.disconnect(); }); it("should accept custom agent configuration on session resume", async () => { // Create a session first - const session1 = await client.createSession(); + const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; await session1.sendAndWait({ prompt: "What is 1+1?" }); @@ -167,6 +171,7 @@ describe("MCP Servers and Custom Agents", async () => { ]; const session2 = await client.resumeSession(sessionId, { + onPermissionRequest: approveAll, customAgents, }); @@ -177,7 +182,7 @@ describe("MCP Servers and Custom Agents", async () => { }); expect(message?.data.content).toContain("12"); - await session2.destroy(); + await session2.disconnect(); }); it("should handle custom agent with tools configuration", async () => { @@ -193,11 +198,12 @@ describe("MCP Servers and Custom Agents", async () => { ]; const session = await client.createSession({ + onPermissionRequest: approveAll, customAgents, }); expect(session.sessionId).toBeDefined(); - await session.destroy(); + await session.disconnect(); }); it("should handle custom agent with MCP servers", async () => { @@ -219,11 +225,12 @@ describe("MCP Servers and Custom Agents", async () => { ]; const session = await client.createSession({ + onPermissionRequest: approveAll, customAgents, }); expect(session.sessionId).toBeDefined(); - await session.destroy(); + await session.disconnect(); }); it("should handle multiple custom agents", async () => { @@ -244,11 +251,12 @@ describe("MCP Servers and Custom Agents", async () => { ]; const session = await client.createSession({ + onPermissionRequest: approveAll, customAgents, }); expect(session.sessionId).toBeDefined(); - await session.destroy(); + await session.disconnect(); }); }); @@ -273,6 +281,7 @@ describe("MCP Servers and Custom Agents", async () => { ]; const session = await client.createSession({ + onPermissionRequest: approveAll, mcpServers, customAgents, }); @@ -284,7 +293,7 @@ describe("MCP Servers and Custom Agents", async () => { }); expect(message?.data.content).toContain("14"); - await session.destroy(); + await session.disconnect(); }); }); }); diff --git a/nodejs/test/e2e/multi-client.test.ts b/nodejs/test/e2e/multi-client.test.ts new file mode 100644 index 000000000..369e84a43 --- /dev/null +++ b/nodejs/test/e2e/multi-client.test.ts @@ -0,0 +1,310 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, afterAll } from "vitest"; +import { z } from "zod"; +import { CopilotClient, defineTool, approveAll } from "../../src/index.js"; +import type { SessionEvent } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext"; + +describe("Multi-client broadcast", async () => { + // Use TCP mode so a second client can connect to the same CLI process + const ctx = await createSdkTestContext({ useStdio: false }); + const client1 = ctx.copilotClient; + + // Trigger connection so we can read the port + const initSession = await client1.createSession({ onPermissionRequest: approveAll }); + await initSession.disconnect(); + + const actualPort = (client1 as unknown as { actualPort: number }).actualPort; + let client2 = new CopilotClient({ cliUrl: `localhost:${actualPort}` }); + + afterAll(async () => { + await client2.stop(); + }); + + it("both clients see tool request and completion events", async () => { + const tool = defineTool("magic_number", { + description: "Returns a magic number", + parameters: z.object({ + seed: z.string().describe("A seed value"), + }), + handler: ({ seed }) => `MAGIC_${seed}_42`, + }); + + // Client 1 creates a session with a custom tool + const session1 = await client1.createSession({ + onPermissionRequest: approveAll, + tools: [tool], + }); + + // Client 2 resumes with NO tools — should not overwrite client 1's tools + const session2 = await client2.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + }); + + // Set up event waiters BEFORE sending the prompt to avoid race conditions + const waitForEvent = (session: typeof session1, type: string) => + new Promise((resolve) => { + const unsub = session.on((event) => { + if (event.type === type) { + unsub(); + resolve(event); + } + }); + }); + + const client1RequestedP = waitForEvent(session1, "external_tool.requested"); + const client2RequestedP = waitForEvent(session2, "external_tool.requested"); + const client1CompletedP = waitForEvent(session1, "external_tool.completed"); + const client2CompletedP = waitForEvent(session2, "external_tool.completed"); + + // Send a prompt that triggers the custom tool + const response = await session1.sendAndWait({ + prompt: "Use the magic_number tool with seed 'hello' and tell me the result", + }); + + // The response should contain the tool's output + expect(response?.data.content).toContain("MAGIC_hello_42"); + + // Wait for all broadcast events to arrive on both clients + await expect( + Promise.all([ + client1RequestedP, + client2RequestedP, + client1CompletedP, + client2CompletedP, + ]) + ).resolves.toBeDefined(); + + await session2.disconnect(); + }); + + it("one client approves permission and both see the result", async () => { + const client1PermissionRequests: unknown[] = []; + + // Client 1 creates a session and manually approves permission requests + const session1 = await client1.createSession({ + onPermissionRequest: (request) => { + client1PermissionRequests.push(request); + return { kind: "approved" as const }; + }, + }); + + // Client 2 resumes the same session — its handler never resolves, + // so only client 1's approval takes effect (no race) + const session2 = await client2.resumeSession(session1.sessionId, { + onPermissionRequest: () => new Promise(() => {}), + }); + + // Track events seen by each client + const client1Events: SessionEvent[] = []; + const client2Events: SessionEvent[] = []; + + session1.on((event) => client1Events.push(event)); + session2.on((event) => client2Events.push(event)); + + // Send a prompt that triggers a write operation (requires permission) + const response = await session1.sendAndWait({ + prompt: "Create a file called hello.txt containing the text 'hello world'", + }); + + expect(response?.data.content).toBeTruthy(); + + // Client 1 should have handled the permission request + expect(client1PermissionRequests.length).toBeGreaterThan(0); + + // Both clients should have seen permission.requested events + const client1PermRequested = client1Events.filter((e) => e.type === "permission.requested"); + const client2PermRequested = client2Events.filter((e) => e.type === "permission.requested"); + expect(client1PermRequested.length).toBeGreaterThan(0); + expect(client2PermRequested.length).toBeGreaterThan(0); + + // Both clients should have seen permission.completed events with approved result + const client1PermCompleted = client1Events.filter( + (e): e is SessionEvent & { type: "permission.completed" } => + e.type === "permission.completed" + ); + const client2PermCompleted = client2Events.filter( + (e): e is SessionEvent & { type: "permission.completed" } => + e.type === "permission.completed" + ); + expect(client1PermCompleted.length).toBeGreaterThan(0); + expect(client2PermCompleted.length).toBeGreaterThan(0); + for (const event of [...client1PermCompleted, ...client2PermCompleted]) { + expect(event.data.result.kind).toBe("approved"); + } + + await session2.disconnect(); + }); + + it("one client rejects permission and both see the result", async () => { + // Client 1 creates a session and denies all permission requests + const session1 = await client1.createSession({ + onPermissionRequest: () => ({ kind: "denied-interactively-by-user" as const }), + }); + + // Client 2 resumes — its handler never resolves so only client 1's denial takes effect + const session2 = await client2.resumeSession(session1.sessionId, { + onPermissionRequest: () => new Promise(() => {}), + }); + + const client1Events: SessionEvent[] = []; + const client2Events: SessionEvent[] = []; + + session1.on((event) => client1Events.push(event)); + session2.on((event) => client2Events.push(event)); + + // Ask the agent to write a file (requires permission) + const { writeFile } = await import("fs/promises"); + const { join } = await import("path"); + const testFile = join(ctx.workDir, "protected.txt"); + await writeFile(testFile, "protected content"); + + await session1.sendAndWait({ + prompt: "Edit protected.txt and replace 'protected' with 'hacked'.", + }); + + // Verify the file was NOT modified (permission was denied) + const { readFile } = await import("fs/promises"); + const content = await readFile(testFile, "utf-8"); + expect(content).toBe("protected content"); + + // Both clients should have seen permission.requested and permission.completed + expect( + client1Events.filter((e) => e.type === "permission.requested").length + ).toBeGreaterThan(0); + expect( + client2Events.filter((e) => e.type === "permission.requested").length + ).toBeGreaterThan(0); + + // Both clients should see the denial in the completed event + const client1PermCompleted = client1Events.filter( + (e): e is SessionEvent & { type: "permission.completed" } => + e.type === "permission.completed" + ); + const client2PermCompleted = client2Events.filter( + (e): e is SessionEvent & { type: "permission.completed" } => + e.type === "permission.completed" + ); + expect(client1PermCompleted.length).toBeGreaterThan(0); + expect(client2PermCompleted.length).toBeGreaterThan(0); + for (const event of [...client1PermCompleted, ...client2PermCompleted]) { + expect(event.data.result.kind).toBe("denied-interactively-by-user"); + } + + await session2.disconnect(); + }); + + it( + "two clients register different tools and agent uses both", + { timeout: 90_000 }, + async () => { + const toolA = defineTool("city_lookup", { + description: "Returns a city name for a given country code", + parameters: z.object({ + countryCode: z.string().describe("A two-letter country code"), + }), + handler: ({ countryCode }) => `CITY_FOR_${countryCode}`, + }); + + const toolB = defineTool("currency_lookup", { + description: "Returns a currency for a given country code", + parameters: z.object({ + countryCode: z.string().describe("A two-letter country code"), + }), + handler: ({ countryCode }) => `CURRENCY_FOR_${countryCode}`, + }); + + // Client 1 creates a session with tool A + const session1 = await client1.createSession({ + onPermissionRequest: approveAll, + tools: [toolA], + }); + + // Client 2 resumes with tool B (different tool, union should have both) + const session2 = await client2.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + tools: [toolB], + }); + + // Send prompts sequentially to avoid nondeterministic tool_call ordering + const response1 = await session1.sendAndWait({ + prompt: "Use the city_lookup tool with countryCode 'US' and tell me the result.", + }); + expect(response1?.data.content).toContain("CITY_FOR_US"); + + const response2 = await session1.sendAndWait({ + prompt: "Now use the currency_lookup tool with countryCode 'US' and tell me the result.", + }); + expect(response2?.data.content).toContain("CURRENCY_FOR_US"); + + await session2.disconnect(); + } + ); + + it("disconnecting client removes its tools", { timeout: 90_000 }, async () => { + const toolA = defineTool("stable_tool", { + description: "A tool that persists across disconnects", + parameters: z.object({ input: z.string() }), + handler: ({ input }) => `STABLE_${input}`, + }); + + const toolB = defineTool("ephemeral_tool", { + description: "A tool that will disappear when its client disconnects", + parameters: z.object({ input: z.string() }), + handler: ({ input }) => `EPHEMERAL_${input}`, + }); + + // Client 1 creates a session with stable_tool + const session1 = await client1.createSession({ + onPermissionRequest: approveAll, + tools: [toolA], + }); + + // Client 2 resumes with ephemeral_tool + await client2.resumeSession(session1.sessionId, { + onPermissionRequest: approveAll, + tools: [toolB], + }); + + // Verify both tools work before disconnect (sequential to avoid nondeterministic tool_call ordering) + const stableResponse = await session1.sendAndWait({ + prompt: "Use the stable_tool with input 'test1' and tell me the result.", + }); + expect(stableResponse?.data.content).toContain("STABLE_test1"); + + const ephemeralResponse = await session1.sendAndWait({ + prompt: "Use the ephemeral_tool with input 'test2' and tell me the result.", + }); + expect(ephemeralResponse?.data.content).toContain("EPHEMERAL_test2"); + + // Disconnect client 2 without destroying the shared session. + // Suppress "Connection is disposed" rejections that occur when the server + // broadcasts events (e.g. tool_changed_notice) to the now-dead connection. + const suppressDisposed = (reason: unknown) => { + if (reason instanceof Error && reason.message.includes("Connection is disposed")) { + return; + } + throw reason; + }; + process.on("unhandledRejection", suppressDisposed); + await client2.forceStop(); + + // Give the server time to process the connection close and remove tools + await new Promise((resolve) => setTimeout(resolve, 500)); + process.removeListener("unhandledRejection", suppressDisposed); + + // Recreate client2 for cleanup in afterAll (but don't rejoin the session) + client2 = new CopilotClient({ cliUrl: `localhost:${actualPort}` }); + + // Now only stable_tool should be available + const afterResponse = await session1.sendAndWait({ + prompt: "Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available.", + }); + expect(afterResponse?.data.content).toContain("STABLE_still_here"); + // ephemeral_tool should NOT have produced a result + expect(afterResponse?.data.content).not.toContain("EPHEMERAL_"); + }); +}); diff --git a/nodejs/test/e2e/multi_turn.test.ts b/nodejs/test/e2e/multi_turn.test.ts new file mode 100644 index 000000000..111fe0970 --- /dev/null +++ b/nodejs/test/e2e/multi_turn.test.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { writeFile } from "fs/promises"; +import { join } from "path"; +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext"; + +describe("Multi-turn Tool Usage", async () => { + const { copilotClient: client, workDir } = await createSdkTestContext(); + + it("should use tool results from previous turns", async () => { + // Write a file, then ask the model to read it and reason about its content + await writeFile(join(workDir, "secret.txt"), "The magic number is 42."); + const session = await client.createSession({ onPermissionRequest: approveAll }); + + const msg1 = await session.sendAndWait({ + prompt: "Read the file 'secret.txt' and tell me what the magic number is.", + }); + expect(msg1?.data.content).toContain("42"); + + // Follow-up that requires context from the previous turn + const msg2 = await session.sendAndWait({ + prompt: "What is that magic number multiplied by 2?", + }); + expect(msg2?.data.content).toContain("84"); + }); + + it("should handle file creation then reading across turns", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + // First turn: create a file + await session.sendAndWait({ + prompt: "Create a file called 'greeting.txt' with the content 'Hello from multi-turn test'.", + }); + + // Second turn: read the file + const msg = await session.sendAndWait({ + prompt: "Read the file 'greeting.txt' and tell me its exact contents.", + }); + expect(msg?.data.content).toContain("Hello from multi-turn test"); + }); +}); diff --git a/nodejs/test/e2e/permissions.test.ts b/nodejs/test/e2e/permissions.test.ts index b68446ee9..2203e34a8 100644 --- a/nodejs/test/e2e/permissions.test.ts +++ b/nodejs/test/e2e/permissions.test.ts @@ -39,7 +39,7 @@ describe("Permission callbacks", async () => { const writeRequests = permissionRequests.filter((req) => req.kind === "write"); expect(writeRequests.length).toBeGreaterThan(0); - await session.destroy(); + await session.disconnect(); }); it("should deny permission when handler returns denied", async () => { @@ -61,13 +61,17 @@ describe("Permission callbacks", async () => { const content = await readFile(testFile, "utf-8"); expect(content).toBe(originalContent); - await session.destroy(); + await session.disconnect(); }); - it("should deny tool operations by default when no handler is provided", async () => { + it("should deny tool operations when handler explicitly denies", async () => { let permissionDenied = false; - const session = await client.createSession(); + const session = await client.createSession({ + onPermissionRequest: () => ({ + kind: "denied-no-approval-rule-and-could-not-request-from-user", + }), + }); session.on((event) => { if ( event.type === "tool.execution_complete" && @@ -82,15 +86,19 @@ describe("Permission callbacks", async () => { expect(permissionDenied).toBe(true); - await session.destroy(); + await session.disconnect(); }); - it("should deny tool operations by default when no handler is provided after resume", async () => { + it("should deny tool operations when handler explicitly denies after resume", async () => { const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; await session1.sendAndWait({ prompt: "What is 1+1?" }); - const session2 = await client.resumeSession(sessionId); + const session2 = await client.resumeSession(sessionId, { + onPermissionRequest: () => ({ + kind: "denied-no-approval-rule-and-could-not-request-from-user", + }), + }); let permissionDenied = false; session2.on((event) => { if ( @@ -106,19 +114,18 @@ describe("Permission callbacks", async () => { expect(permissionDenied).toBe(true); - await session2.destroy(); + await session2.disconnect(); }); - it("should work without permission handler (default behavior)", async () => { - // Create session without onPermissionRequest handler - const session = await client.createSession(); + it("should work with approve-all permission handler", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); const message = await session.sendAndWait({ prompt: "What is 2+2?", }); expect(message?.data.content).toContain("4"); - await session.destroy(); + await session.disconnect(); }); it("should handle async permission handler", async () => { @@ -141,14 +148,14 @@ describe("Permission callbacks", async () => { expect(permissionRequests.length).toBeGreaterThan(0); - await session.destroy(); + await session.disconnect(); }); it("should resume session with permission handler", async () => { const permissionRequests: PermissionRequest[] = []; - // Create session without permission handler - const session1 = await client.createSession(); + // Create initial session + const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; await session1.sendAndWait({ prompt: "What is 1+1?" }); @@ -167,7 +174,7 @@ describe("Permission callbacks", async () => { // Should have permission requests from resumed session expect(permissionRequests.length).toBeGreaterThan(0); - await session2.destroy(); + await session2.disconnect(); }); it("should handle permission handler errors gracefully", async () => { @@ -184,7 +191,7 @@ describe("Permission callbacks", async () => { // Should handle the error and deny permission expect(message?.data.content?.toLowerCase()).toMatch(/fail|cannot|unable|permission/); - await session.destroy(); + await session.disconnect(); }); it("should receive toolCallId in permission requests", async () => { @@ -207,6 +214,6 @@ describe("Permission callbacks", async () => { expect(receivedToolCallId).toBe(true); - await session.destroy(); + await session.disconnect(); }); }); diff --git a/nodejs/test/e2e/rpc.test.ts b/nodejs/test/e2e/rpc.test.ts index b7acbaf66..62a885d05 100644 --- a/nodejs/test/e2e/rpc.test.ts +++ b/nodejs/test/e2e/rpc.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, onTestFinished } from "vitest"; -import { CopilotClient } from "../../src/index.js"; +import { CopilotClient, approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; function onTestFinishedForceStop(client: CopilotClient) { @@ -71,7 +71,10 @@ describe("Session RPC", async () => { // session.model.getCurrent is defined in schema but not yet implemented in CLI it.skip("should call session.rpc.model.getCurrent", async () => { - const session = await client.createSession({ model: "claude-sonnet-4.5" }); + const session = await client.createSession({ + onPermissionRequest: approveAll, + model: "claude-sonnet-4.5", + }); const result = await session.rpc.model.getCurrent(); expect(result.modelId).toBeDefined(); @@ -80,7 +83,10 @@ describe("Session RPC", async () => { // session.model.switchTo is defined in schema but not yet implemented in CLI it.skip("should call session.rpc.model.switchTo", async () => { - const session = await client.createSession({ model: "claude-sonnet-4.5" }); + const session = await client.createSession({ + onPermissionRequest: approveAll, + model: "claude-sonnet-4.5", + }); // Get initial model const before = await session.rpc.model.getCurrent(); @@ -96,7 +102,7 @@ describe("Session RPC", async () => { }); it("should get and set session mode", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); // Get initial mode (default should be interactive) const initial = await session.rpc.mode.get(); @@ -116,7 +122,7 @@ describe("Session RPC", async () => { }); it("should read, update, and delete plan", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); // Initially plan should not exist const initial = await session.rpc.plan.read(); @@ -142,7 +148,7 @@ describe("Session RPC", async () => { }); it("should create, list, and read workspace files", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); // Initially no files const initialFiles = await session.rpc.workspace.listFiles(); diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index 09c293a53..e988e62c8 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -1,14 +1,18 @@ +import { rm } from "fs/promises"; import { describe, expect, it, onTestFinished } from "vitest"; import { ParsedHttpExchange } from "../../../test/harness/replayingCapiProxy.js"; import { CopilotClient, approveAll } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { createSdkTestContext, isCI } from "./harness/sdkTestContext.js"; import { getFinalAssistantMessage, getNextEventOfType } from "./harness/sdkTestHelper.js"; describe("Sessions", async () => { const { copilotClient: client, openAiEndpoint, homeDir, env } = await createSdkTestContext(); - it("should create and destroy sessions", async () => { - const session = await client.createSession({ model: "fake-test-model" }); + it("should create and disconnect sessions", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + model: "fake-test-model", + }); expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); expect(await session.getMessages()).toMatchObject([ @@ -18,14 +22,14 @@ describe("Sessions", async () => { }, ]); - await session.destroy(); + await session.disconnect(); await expect(() => session.getMessages()).rejects.toThrow(/Session not found/); }); // TODO: Re-enable once test harness CAPI proxy supports this test's session lifecycle it.skip("should list sessions with context field", { timeout: 60000 }, async () => { // Create a session — just creating it is enough for it to appear in listSessions - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); // Verify it has a start event (confirms session is active) @@ -44,7 +48,7 @@ describe("Sessions", async () => { }); it("should have stateful conversation", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); const assistantMessage = await session.sendAndWait({ prompt: "What is 1+1?" }); expect(assistantMessage?.data.content).toContain("2"); @@ -57,6 +61,7 @@ describe("Sessions", async () => { it("should create a session with appended systemMessage config", async () => { const systemMessageSuffix = "End each response with the phrase 'Have a nice day!'"; const session = await client.createSession({ + onPermissionRequest: approveAll, systemMessage: { mode: "append", content: systemMessageSuffix, @@ -77,6 +82,7 @@ describe("Sessions", async () => { it("should create a session with replaced systemMessage config", async () => { const testSystemMessage = "You are an assistant called Testy McTestface. Reply succinctly."; const session = await client.createSession({ + onPermissionRequest: approveAll, systemMessage: { mode: "replace", content: testSystemMessage }, }); @@ -92,6 +98,7 @@ describe("Sessions", async () => { it("should create a session with availableTools", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, availableTools: ["view", "edit"], }); @@ -107,6 +114,7 @@ describe("Sessions", async () => { it("should create a session with excludedTools", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, excludedTools: ["view"], }); @@ -128,9 +136,9 @@ describe("Sessions", async () => { // we stopped all the clients (one or more child processes were left orphaned). it.skip("should handle multiple concurrent sessions", async () => { const [s1, s2, s3] = await Promise.all([ - client.createSession(), - client.createSession(), - client.createSession(), + client.createSession({ onPermissionRequest: approveAll }), + client.createSession({ onPermissionRequest: approveAll }), + client.createSession({ onPermissionRequest: approveAll }), ]); // All sessions should have unique IDs @@ -147,8 +155,8 @@ describe("Sessions", async () => { ]); } - // All can be destroyed - await Promise.all([s1.destroy(), s2.destroy(), s3.destroy()]); + // All can be disconnected + await Promise.all([s1.disconnect(), s2.disconnect(), s3.disconnect()]); for (const s of [s1, s2, s3]) { await expect(() => s.getMessages()).rejects.toThrow(/Session not found/); } @@ -156,22 +164,28 @@ describe("Sessions", async () => { it("should resume a session using the same client", async () => { // Create initial session - const session1 = await client.createSession(); + const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; const answer = await session1.sendAndWait({ prompt: "What is 1+1?" }); expect(answer?.data.content).toContain("2"); // Resume using the same client - const session2 = await client.resumeSession(sessionId); + const session2 = await client.resumeSession(sessionId, { onPermissionRequest: approveAll }); expect(session2.sessionId).toBe(sessionId); const messages = await session2.getMessages(); const assistantMessages = messages.filter((m) => m.type === "assistant.message"); expect(assistantMessages[assistantMessages.length - 1].data.content).toContain("2"); + + // Can continue the conversation statefully + const secondAssistantMessage = await session2.sendAndWait({ + prompt: "Now if you double that, what do you get?", + }); + expect(secondAssistantMessage?.data.content).toContain("4"); }); it("should resume a session using a new client", async () => { // Create initial session - const session1 = await client.createSession(); + const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; const answer = await session1.sendAndWait({ prompt: "What is 1+1?" }); expect(answer?.data.content).toContain("2"); @@ -179,11 +193,13 @@ describe("Sessions", async () => { // Resume using a new client const newClient = new CopilotClient({ env, - githubToken: process.env.CI === "true" ? "fake-token-for-e2e-tests" : undefined, + githubToken: isCI ? "fake-token-for-e2e-tests" : undefined, }); onTestFinished(() => newClient.forceStop()); - const session2 = await newClient.resumeSession(sessionId); + const session2 = await newClient.resumeSession(sessionId, { + onPermissionRequest: approveAll, + }); expect(session2.sessionId).toBe(sessionId); // TODO: There's an inconsistency here. When resuming with a new client, we don't see @@ -192,14 +208,23 @@ describe("Sessions", async () => { const messages = await session2.getMessages(); expect(messages).toContainEqual(expect.objectContaining({ type: "user.message" })); expect(messages).toContainEqual(expect.objectContaining({ type: "session.resume" })); + + // Can continue the conversation statefully + const secondAssistantMessage = await session2.sendAndWait({ + prompt: "Now if you double that, what do you get?", + }); + expect(secondAssistantMessage?.data.content).toContain("4"); }); it("should throw error when resuming non-existent session", async () => { - await expect(client.resumeSession("non-existent-session-id")).rejects.toThrow(); + await expect( + client.resumeSession("non-existent-session-id", { onPermissionRequest: approveAll }) + ).rejects.toThrow(); }); it("should create session with custom tool", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, tools: [ { name: "get_secret_number", @@ -229,11 +254,12 @@ describe("Sessions", async () => { }); it("should resume session with a custom provider", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session.sessionId; // Resume the session with a provider const session2 = await client.resumeSession(sessionId, { + onPermissionRequest: approveAll, provider: { type: "openai", baseUrl: "https://api.openai.com/v1", @@ -245,7 +271,7 @@ describe("Sessions", async () => { }); it("should abort a session", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); // Set up event listeners BEFORE sending to avoid race conditions const nextToolCallStart = getNextEventOfType(session, "tool.execution_start"); @@ -270,56 +296,8 @@ describe("Sessions", async () => { expect(answer?.data.content).toContain("4"); }); - it("should receive streaming delta events when streaming is enabled", async () => { - const session = await client.createSession({ - streaming: true, - }); - - const deltaContents: string[] = []; - let _finalMessage: string | undefined; - - // Set up event listener before sending - const unsubscribe = session.on((event) => { - if (event.type === "assistant.message_delta") { - const delta = (event.data as { deltaContent?: string }).deltaContent; - if (delta) { - deltaContents.push(delta); - } - } else if (event.type === "assistant.message") { - _finalMessage = event.data.content; - } - }); - - const assistantMessage = await session.sendAndWait({ prompt: "What is 2+2?" }); - - unsubscribe(); - - // Should have received delta events - expect(deltaContents.length).toBeGreaterThan(0); - - // Accumulated deltas should equal the final message - const accumulated = deltaContents.join(""); - expect(accumulated).toBe(assistantMessage?.data.content); - - // Final message should contain the answer - expect(assistantMessage?.data.content).toContain("4"); - }); - - it("should pass streaming option to session creation", async () => { - // Verify that the streaming option is accepted without errors - const session = await client.createSession({ - streaming: true, - }); - - expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); - - // Session should still work normally - const assistantMessage = await session.sendAndWait({ prompt: "What is 1+1?" }); - expect(assistantMessage?.data.content).toContain("2"); - }); - it("should receive session events", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); const receivedEvents: Array<{ type: string }> = []; session.on((event) => { @@ -341,7 +319,11 @@ describe("Sessions", async () => { it("should create session with custom config dir", async () => { const customConfigDir = `${homeDir}/custom-config`; + onTestFinished(async () => { + await rm(customConfigDir, { recursive: true, force: true }).catch(() => {}); + }); const session = await client.createSession({ + onPermissionRequest: approveAll, configDir: customConfigDir, }); @@ -390,7 +372,7 @@ describe("Send Blocking Behavior", async () => { }); it("sendAndWait blocks until session.idle and returns final assistant message", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); const events: string[] = []; session.on((event) => { @@ -409,7 +391,7 @@ describe("Send Blocking Behavior", async () => { // This test validates client-side timeout behavior. // The snapshot has no assistant response since we expect timeout before completion. it("sendAndWait throws on timeout", async () => { - const session = await client.createSession(); + const session = await client.createSession({ onPermissionRequest: approveAll }); // Use a slow command to ensure timeout triggers before completion await expect( diff --git a/nodejs/test/e2e/session_config.test.ts b/nodejs/test/e2e/session_config.test.ts new file mode 100644 index 000000000..2984c3c04 --- /dev/null +++ b/nodejs/test/e2e/session_config.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { writeFile, mkdir } from "fs/promises"; +import { join } from "path"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("Session Configuration", async () => { + const { copilotClient: client, workDir } = await createSdkTestContext(); + + it("should use workingDirectory for tool execution", async () => { + const subDir = join(workDir, "subproject"); + await mkdir(subDir, { recursive: true }); + await writeFile(join(subDir, "marker.txt"), "I am in the subdirectory"); + + const session = await client.createSession({ + onPermissionRequest: approveAll, + workingDirectory: subDir, + }); + + const assistantMessage = await session.sendAndWait({ + prompt: "Read the file marker.txt and tell me what it says", + }); + expect(assistantMessage?.data.content).toContain("subdirectory"); + + await session.disconnect(); + }); + + it("should create session with custom provider config", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + provider: { + baseUrl: "https://api.example.com/v1", + apiKey: "test-key", + }, + }); + + expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); + + try { + await session.disconnect(); + } catch { + // disconnect may fail since the provider is fake + } + }); + + it("should accept message attachments", async () => { + await writeFile(join(workDir, "attached.txt"), "This file is attached"); + + const session = await client.createSession({ onPermissionRequest: approveAll }); + + await session.send({ + prompt: "Summarize the attached file", + attachments: [{ type: "file", path: join(workDir, "attached.txt") }], + }); + + // Just verify send doesn't throw — attachment support varies by runtime + await session.disconnect(); + }); +}); diff --git a/nodejs/test/e2e/session_lifecycle.test.ts b/nodejs/test/e2e/session_lifecycle.test.ts new file mode 100644 index 000000000..355f89980 --- /dev/null +++ b/nodejs/test/e2e/session_lifecycle.test.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { SessionEvent, approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext"; + +describe("Session Lifecycle", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should list created sessions after sending a message", async () => { + const session1 = await client.createSession({ onPermissionRequest: approveAll }); + const session2 = await client.createSession({ onPermissionRequest: approveAll }); + + // Sessions must have activity to be persisted to disk + await session1.sendAndWait({ prompt: "Say hello" }); + await session2.sendAndWait({ prompt: "Say world" }); + + // Wait for session data to flush to disk + await new Promise((r) => setTimeout(r, 500)); + + const sessions = await client.listSessions(); + const sessionIds = sessions.map((s) => s.sessionId); + + expect(sessionIds).toContain(session1.sessionId); + expect(sessionIds).toContain(session2.sessionId); + + await session1.disconnect(); + await session2.disconnect(); + }); + + it("should delete session permanently", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + const sessionId = session.sessionId; + + // Send a message so the session is persisted + await session.sendAndWait({ prompt: "Say hi" }); + + // Wait for session data to flush to disk + await new Promise((r) => setTimeout(r, 500)); + + // Verify it appears in the list + const before = await client.listSessions(); + expect(before.map((s) => s.sessionId)).toContain(sessionId); + + await session.disconnect(); + await client.deleteSession(sessionId); + + // After delete, the session should not be in the list + const after = await client.listSessions(); + expect(after.map((s) => s.sessionId)).not.toContain(sessionId); + }); + + it("should return events via getMessages after conversation", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + await session.sendAndWait({ + prompt: "What is 2+2? Reply with just the number.", + }); + + const messages = await session.getMessages(); + expect(messages.length).toBeGreaterThan(0); + + // Should have at least session.start, user.message, assistant.message, session.idle + const types = messages.map((m: SessionEvent) => m.type); + expect(types).toContain("session.start"); + expect(types).toContain("user.message"); + expect(types).toContain("assistant.message"); + + await session.disconnect(); + }); + + it("should support multiple concurrent sessions", async () => { + const session1 = await client.createSession({ onPermissionRequest: approveAll }); + const session2 = await client.createSession({ onPermissionRequest: approveAll }); + + // Send to both sessions + const [msg1, msg2] = await Promise.all([ + session1.sendAndWait({ prompt: "What is 1+1? Reply with just the number." }), + session2.sendAndWait({ prompt: "What is 3+3? Reply with just the number." }), + ]); + + expect(msg1?.data.content).toContain("2"); + expect(msg2?.data.content).toContain("6"); + + await session1.disconnect(); + await session2.disconnect(); + }); +}); diff --git a/nodejs/test/e2e/skills.test.ts b/nodejs/test/e2e/skills.test.ts index 92186ec0b..a2173648f 100644 --- a/nodejs/test/e2e/skills.test.ts +++ b/nodejs/test/e2e/skills.test.ts @@ -5,6 +5,7 @@ import * as fs from "fs"; import * as path from "path"; import { beforeEach, describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; describe("Skills Configuration", async () => { @@ -44,6 +45,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY it("should load and apply skill from skillDirectories", async () => { const skillsDir = createSkillDir(); const session = await client.createSession({ + onPermissionRequest: approveAll, skillDirectories: [skillsDir], }); @@ -56,12 +58,13 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY expect(message?.data.content).toContain(SKILL_MARKER); - await session.destroy(); + await session.disconnect(); }); it("should not apply skill when disabled via disabledSkills", async () => { const skillsDir = createSkillDir(); const session = await client.createSession({ + onPermissionRequest: approveAll, skillDirectories: [skillsDir], disabledSkills: ["test-skill"], }); @@ -75,7 +78,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY expect(message?.data.content).not.toContain(SKILL_MARKER); - await session.destroy(); + await session.disconnect(); }); // Skipped because the underlying feature doesn't work correctly yet. @@ -93,7 +96,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY const skillsDir = createSkillDir(); // Create a session without skills first - const session1 = await client.createSession(); + const session1 = await client.createSession({ onPermissionRequest: approveAll }); const sessionId = session1.sessionId; // First message without skill - marker should not appear @@ -102,6 +105,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY // Resume with skillDirectories - skill should now be active const session2 = await client.resumeSession(sessionId, { + onPermissionRequest: approveAll, skillDirectories: [skillsDir], }); @@ -114,7 +118,7 @@ IMPORTANT: You MUST include the exact text "${SKILL_MARKER}" somewhere in EVERY expect(message2?.data.content).toContain(SKILL_MARKER); - await session2.destroy(); + await session2.disconnect(); }); }); }); diff --git a/nodejs/test/e2e/streaming_fidelity.test.ts b/nodejs/test/e2e/streaming_fidelity.test.ts new file mode 100644 index 000000000..11edee1ca --- /dev/null +++ b/nodejs/test/e2e/streaming_fidelity.test.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, onTestFinished } from "vitest"; +import { CopilotClient, SessionEvent, approveAll } from "../../src/index.js"; +import { createSdkTestContext, isCI } from "./harness/sdkTestContext"; + +describe("Streaming Fidelity", async () => { + const { copilotClient: client, env } = await createSdkTestContext(); + + it("should produce delta events when streaming is enabled", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + streaming: true, + }); + const events: SessionEvent[] = []; + session.on((event) => { + events.push(event); + }); + + await session.sendAndWait({ + prompt: "Count from 1 to 5, separated by commas.", + }); + + const types = events.map((e) => e.type); + + // Should have streaming deltas before the final message + const deltaEvents = events.filter((e) => e.type === "assistant.message_delta"); + expect(deltaEvents.length).toBeGreaterThanOrEqual(1); + + // Deltas should have content + for (const delta of deltaEvents) { + expect(delta.data.deltaContent).toBeDefined(); + expect(typeof delta.data.deltaContent).toBe("string"); + } + + // Should still have a final assistant.message + expect(types).toContain("assistant.message"); + + // Deltas should come before the final message + const firstDeltaIdx = types.indexOf("assistant.message_delta"); + const lastAssistantIdx = types.lastIndexOf("assistant.message"); + expect(firstDeltaIdx).toBeLessThan(lastAssistantIdx); + + await session.disconnect(); + }); + + it("should not produce deltas when streaming is disabled", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + streaming: false, + }); + const events: SessionEvent[] = []; + session.on((event) => { + events.push(event); + }); + + await session.sendAndWait({ + prompt: "Say 'hello world'.", + }); + + const deltaEvents = events.filter((e) => e.type === "assistant.message_delta"); + + // No deltas when streaming is off + expect(deltaEvents.length).toBe(0); + + // But should still have a final assistant.message + const assistantEvents = events.filter((e) => e.type === "assistant.message"); + expect(assistantEvents.length).toBeGreaterThanOrEqual(1); + + await session.disconnect(); + }); + + it("should produce deltas after session resume", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + streaming: false, + }); + await session.sendAndWait({ prompt: "What is 3 + 6?" }); + await session.disconnect(); + + // Resume using a new client + const newClient = new CopilotClient({ + env, + githubToken: isCI ? "fake-token-for-e2e-tests" : undefined, + }); + onTestFinished(() => newClient.forceStop()); + const session2 = await newClient.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + streaming: true, + }); + const events: SessionEvent[] = []; + session2.on((event) => events.push(event)); + + const secondAssistantMessage = await session2.sendAndWait({ + prompt: "Now if you double that, what do you get?", + }); + expect(secondAssistantMessage?.data.content).toContain("18"); + + // Should have streaming deltas before the final message + const deltaEvents = events.filter((e) => e.type === "assistant.message_delta"); + expect(deltaEvents.length).toBeGreaterThanOrEqual(1); + + // Deltas should have content + for (const delta of deltaEvents) { + expect(delta.data.deltaContent).toBeDefined(); + expect(typeof delta.data.deltaContent).toBe("string"); + } + + await session2.disconnect(); + }); +}); diff --git a/nodejs/test/e2e/tool_results.test.ts b/nodejs/test/e2e/tool_results.test.ts new file mode 100644 index 000000000..66e715490 --- /dev/null +++ b/nodejs/test/e2e/tool_results.test.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import type { ToolResultObject } from "../../src/index.js"; +import { approveAll, defineTool } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext"; + +describe("Tool Results", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should handle structured ToolResultObject from custom tool", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + defineTool("get_weather", { + description: "Gets weather for a city", + parameters: z.object({ + city: z.string(), + }), + handler: ({ city }): ToolResultObject => ({ + textResultForLlm: `The weather in ${city} is sunny and 72°F`, + resultType: "success", + }), + }), + ], + }); + + const assistantMessage = await session.sendAndWait({ + prompt: "What's the weather in Paris?", + }); + + const content = assistantMessage?.data.content ?? ""; + expect(content).toMatch(/sunny|72/i); + + await session.disconnect(); + }); + + it("should handle tool result with failure resultType", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + defineTool("check_status", { + description: "Checks the status of a service", + handler: (): ToolResultObject => ({ + textResultForLlm: "Service unavailable", + resultType: "failure", + error: "API timeout", + }), + }), + ], + }); + + const assistantMessage = await session.sendAndWait({ + prompt: "Check the status of the service using check_status. If it fails, say 'service is down'.", + }); + + const failureContent = assistantMessage?.data.content ?? ""; + expect(failureContent).toMatch(/service is down/i); + + await session.disconnect(); + }); + + it("should pass validated Zod parameters to tool handler", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + defineTool("calculate", { + description: "Calculates a math expression", + parameters: z.object({ + operation: z.enum(["add", "subtract", "multiply"]), + a: z.number(), + b: z.number(), + }), + handler: ({ operation, a, b }) => { + expect(typeof a).toBe("number"); + expect(typeof b).toBe("number"); + switch (operation) { + case "add": + return String(a + b); + case "subtract": + return String(a - b); + case "multiply": + return String(a * b); + } + }, + }), + ], + }); + + const assistantMessage = await session.sendAndWait({ + prompt: "Use calculate to add 17 and 25", + }); + + expect(assistantMessage?.data.content).toContain("42"); + + await session.disconnect(); + }); +}); diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index 3db24dff7..3f5c3e09f 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -7,6 +7,7 @@ import { join } from "path"; import { assert, describe, expect, it } from "vitest"; import { z } from "zod"; import { defineTool, approveAll } from "../../src/index.js"; +import type { PermissionRequest } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext"; describe("Custom tools", async () => { @@ -26,6 +27,7 @@ describe("Custom tools", async () => { it("invokes custom tool", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, tools: [ defineTool("encrypt_string", { description: "Encrypts a string", @@ -45,6 +47,7 @@ describe("Custom tools", async () => { it("handles tool calling errors", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, tools: [ defineTool("get_user_location", { description: "Gets the user's location", @@ -85,6 +88,7 @@ describe("Custom tools", async () => { it("can receive and return complex types", async () => { const session = await client.createSession({ + onPermissionRequest: approveAll, tools: [ defineTool("db_query", { description: "Performs a database query", @@ -124,4 +128,84 @@ describe("Custom tools", async () => { expect(responseContent.replace(/,/g, "")).toContain("135460"); expect(responseContent.replace(/,/g, "")).toContain("204356"); }); + + it("invokes custom tool with permission handler", async () => { + const permissionRequests: PermissionRequest[] = []; + + const session = await client.createSession({ + tools: [ + defineTool("encrypt_string", { + description: "Encrypts a string", + parameters: z.object({ + input: z.string().describe("String to encrypt"), + }), + handler: ({ input }) => input.toUpperCase(), + }), + ], + onPermissionRequest: (request) => { + permissionRequests.push(request); + return { kind: "approved" }; + }, + }); + + const assistantMessage = await session.sendAndWait({ + prompt: "Use encrypt_string to encrypt this string: Hello", + }); + expect(assistantMessage?.data.content).toContain("HELLO"); + + // Should have received a custom-tool permission request + const customToolRequests = permissionRequests.filter((req) => req.kind === "custom-tool"); + expect(customToolRequests.length).toBeGreaterThan(0); + expect(customToolRequests[0].toolName).toBe("encrypt_string"); + }); + + it("overrides built-in tool with custom tool", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [ + defineTool("grep", { + description: "A custom grep implementation that overrides the built-in", + parameters: z.object({ + query: z.string().describe("Search query"), + }), + handler: ({ query }) => `CUSTOM_GREP_RESULT: ${query}`, + overridesBuiltInTool: true, + }), + ], + }); + + const assistantMessage = await session.sendAndWait({ + prompt: "Use grep to search for the word 'hello'", + }); + expect(assistantMessage?.data.content).toContain("CUSTOM_GREP_RESULT"); + }); + + it("denies custom tool when permission denied", async () => { + let toolHandlerCalled = false; + + const session = await client.createSession({ + tools: [ + defineTool("encrypt_string", { + description: "Encrypts a string", + parameters: z.object({ + input: z.string().describe("String to encrypt"), + }), + handler: ({ input }) => { + toolHandlerCalled = true; + return input.toUpperCase(); + }, + }), + ], + onPermissionRequest: () => { + return { kind: "denied-interactively-by-user" }; + }, + }); + + await session.sendAndWait({ + prompt: "Use encrypt_string to encrypt this string: Hello", + }); + + // The tool handler should NOT have been called since permission was denied + expect(toolHandlerCalled).toBe(false); + }); }); diff --git a/python/README.md b/python/README.md index aa82e0c34..5b87bb04e 100644 --- a/python/README.md +++ b/python/README.md @@ -51,12 +51,20 @@ async def main(): await done.wait() # Clean up - await session.destroy() + await session.disconnect() await client.stop() asyncio.run(main()) ``` +Sessions also support the `async with` context manager pattern for automatic cleanup: + +```python +async with await client.create_session({"model": "gpt-5"}) as session: + await session.send({"prompt": "What is 2+2?"}) + # session is automatically disconnected when leaving the block +``` + ## Features - ✅ Full JSON-RPC protocol support @@ -90,7 +98,7 @@ await session.send({"prompt": "Hello!"}) # ... wait for events ... -await session.destroy() +await session.disconnect() await client.stop() ``` @@ -210,6 +218,20 @@ session = await client.create_session({ The SDK automatically handles `tool.call`, executes your handler (sync or async), and responds with the final result when the tool completes. +#### Overriding Built-in Tools + +If you register a tool with the same name as a built-in CLI tool (e.g. `edit_file`, `read_file`), the SDK will throw an error unless you explicitly opt in by setting `overrides_built_in_tool=True`. This flag signals that you intend to replace the built-in tool with your custom implementation. + +```python +class EditFileParams(BaseModel): + path: str = Field(description="File path") + content: str = Field(description="New file content") + +@define_tool(name="edit_file", description="Custom file editor with project-specific validation", overrides_built_in_tool=True) +async def edit_file(params: EditFileParams) -> str: + # your logic +``` + ## Image Support The SDK supports image attachments via the `attachments` parameter. You can attach images by providing their file path: @@ -277,7 +299,7 @@ async def main(): await session.send({"prompt": "Tell me a short story"}) await done.wait() # Wait for streaming to complete - await session.destroy() + await session.disconnect() await client.stop() asyncio.run(main()) @@ -401,11 +423,11 @@ async def handle_user_input(request, invocation): # request["question"] - The question to ask # request.get("choices") - Optional list of choices for multiple choice # request.get("allowFreeform", True) - Whether freeform input is allowed - + print(f"Agent asks: {request['question']}") if request.get("choices"): print(f"Choices: {', '.join(request['choices'])}") - + # Return the user's response return { "answer": "User's answer here", @@ -483,5 +505,5 @@ session = await client.create_session({ ## Requirements -- Python 3.9+ +- Python 3.11+ - GitHub Copilot CLI installed and accessible diff --git a/python/copilot/client.py b/python/copilot/client.py index 90260ffbd..7ea4e97a1 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -19,12 +19,12 @@ import subprocess import sys import threading -from dataclasses import asdict, is_dataclass +from collections.abc import Callable from pathlib import Path -from typing import Any, Callable, Optional, cast +from typing import Any, cast from .generated.rpc import ServerRpc -from .generated.session_events import session_event_from_dict +from .generated.session_events import PermissionRequest, session_event_from_dict from .jsonrpc import JsonRpcClient, ProcessExitedError from .sdk_protocol_version import get_sdk_protocol_version from .session import CopilotSession @@ -45,13 +45,16 @@ SessionListFilter, SessionMetadata, StopError, - ToolHandler, ToolInvocation, ToolResult, ) +# Minimum protocol version this SDK can communicate with. +# Servers reporting a version below this are rejected. +MIN_PROTOCOL_VERSION = 2 -def _get_bundled_cli_path() -> Optional[str]: + +def _get_bundled_cli_path() -> str | None: """Get the path to the bundled CLI binary, if available.""" # The binary is bundled in copilot/bin/ within the package bin_dir = Path(__file__).parent / "bin" @@ -91,19 +94,22 @@ class CopilotClient: >>> await client.start() >>> >>> # Create a session and send a message - >>> session = await client.create_session({"model": "gpt-4"}) + >>> session = await client.create_session({ + ... "on_permission_request": PermissionHandler.approve_all, + ... "model": "gpt-4", + ... }) >>> session.on(lambda event: print(event.type)) >>> await session.send({"prompt": "Hello!"}) >>> >>> # Clean up - >>> await session.destroy() + >>> await session.disconnect() >>> await client.stop() >>> # Or connect to an existing server >>> client = CopilotClient({"cli_url": "localhost:3000"}) """ - def __init__(self, options: Optional[CopilotClientOptions] = None): + def __init__(self, options: CopilotClientOptions | None = None): """ Initialize a new CopilotClient. @@ -148,7 +154,7 @@ def __init__(self, options: Optional[CopilotClientOptions] = None): self._is_external_server: bool = False if opts.get("cli_url"): self._actual_host, actual_port = self._parse_cli_url(opts["cli_url"]) - self._actual_port: Optional[int] = actual_port + self._actual_port: int | None = actual_port self._is_external_server = True else: self._actual_port = None @@ -194,19 +200,20 @@ def __init__(self, options: Optional[CopilotClientOptions] = None): if github_token: self.options["github_token"] = github_token - self._process: Optional[subprocess.Popen] = None - self._client: Optional[JsonRpcClient] = None + self._process: subprocess.Popen | None = None + self._client: JsonRpcClient | None = None self._state: ConnectionState = "disconnected" self._sessions: dict[str, CopilotSession] = {} self._sessions_lock = threading.Lock() - self._models_cache: Optional[list[ModelInfo]] = None + self._models_cache: list[ModelInfo] | None = None self._models_cache_lock = asyncio.Lock() self._lifecycle_handlers: list[SessionLifecycleHandler] = [] self._typed_lifecycle_handlers: dict[ SessionLifecycleEventType, list[SessionLifecycleHandler] ] = {} self._lifecycle_handlers_lock = threading.Lock() - self._rpc: Optional[ServerRpc] = None + self._rpc: ServerRpc | None = None + self._negotiated_protocol_version: int | None = None @property def rpc(self) -> ServerRpc: @@ -215,6 +222,16 @@ def rpc(self) -> ServerRpc: raise RuntimeError("Client is not connected. Call start() first.") return self._rpc + @property + def actual_port(self) -> int | None: + """The actual TCP port the CLI server is listening on, if using TCP transport. + + Useful for multi-client scenarios where a second client needs to connect + to the same server. Only available after :meth:`start` completes and + only when not using stdio transport. + """ + return self._actual_port + def _parse_cli_url(self, url: str) -> tuple[str, int]: """ Parse CLI URL into host and port. @@ -311,23 +328,27 @@ async def start(self) -> None: ) from e raise - async def stop(self) -> list["StopError"]: + async def stop(self) -> None: """ Stop the CLI server and close all active sessions. This method performs graceful cleanup: - 1. Destroys all active sessions + 1. Closes all active sessions (releases in-memory resources) 2. Closes the JSON-RPC connection 3. Terminates the CLI server process (if spawned by this client) - Returns: - A list of StopError objects containing error messages that occurred - during cleanup. An empty list indicates all cleanup succeeded. + Note: session data on disk is preserved, so sessions can be resumed + later. To permanently remove session data before stopping, call + :meth:`delete_session` for each session first. + + Raises: + ExceptionGroup[StopError]: If any errors occurred during cleanup. Example: - >>> errors = await client.stop() - >>> if errors: - ... for error in errors: + >>> try: + ... await client.stop() + ... except* StopError as eg: + ... for error in eg.exceptions: ... print(f"Cleanup error: {error.message}") """ errors: list[StopError] = [] @@ -340,10 +361,10 @@ async def stop(self) -> list["StopError"]: for session in sessions_to_destroy: try: - await session.destroy() + await session.disconnect() except Exception as e: errors.append( - StopError(message=f"Failed to destroy session {session.session_id}: {e}") + StopError(message=f"Failed to disconnect session {session.session_id}: {e}") ) # Close client @@ -356,7 +377,6 @@ async def stop(self) -> list["StopError"]: async with self._models_cache_lock: self._models_cache = None - # Kill CLI process # Kill CLI process (only if we spawned it) if self._process and not self._is_external_server: self._process.terminate() @@ -370,7 +390,8 @@ async def stop(self) -> list["StopError"]: if not self._is_external_server: self._actual_port = None - return errors + if errors: + raise ExceptionGroup("errors during CopilotClient.stop()", errors) async def force_stop(self) -> None: """ @@ -378,7 +399,7 @@ async def force_stop(self) -> None: Use this when :meth:`stop` fails or takes too long. This method: - Clears all sessions immediately without destroying them - - Force closes the connection + - Force closes the connection (closes the underlying transport) - Kills the CLI process (if spawned by this client) Example: @@ -392,7 +413,20 @@ async def force_stop(self) -> None: with self._sessions_lock: self._sessions.clear() - # Force close connection + # Close the transport first to signal the server immediately. + # For external servers (TCP), this closes the socket. + # For spawned processes (stdio), this kills the process. + if self._process: + try: + if self._is_external_server: + self._process.terminate() # closes the TCP socket + else: + self._process.kill() + self._process = None + except Exception: + pass + + # Then clean up the JSON-RPC client if self._client: try: await self._client.stop() @@ -405,16 +439,11 @@ async def force_stop(self) -> None: async with self._models_cache_lock: self._models_cache = None - # Kill CLI process immediately - if self._process and not self._is_external_server: - self._process.kill() - self._process = None - self._state = "disconnected" if not self._is_external_server: self._actual_port = None - async def create_session(self, config: Optional[SessionConfig] = None) -> CopilotSession: + async def create_session(self, config: SessionConfig) -> CopilotSession: """ Create a new conversation session with the Copilot CLI. @@ -434,10 +463,12 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo Example: >>> # Basic session - >>> session = await client.create_session() + >>> config = {"on_permission_request": PermissionHandler.approve_all} + >>> session = await client.create_session(config) >>> >>> # Session with model and streaming >>> session = await client.create_session({ + ... "on_permission_request": PermissionHandler.approve_all, ... "model": "gpt-4", ... "streaming": True ... }) @@ -448,18 +479,27 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo else: raise RuntimeError("Client not connected. Call start() first.") - cfg = config or {} + cfg = config + + if not cfg.get("on_permission_request"): + raise ValueError( + "An on_permission_request handler is required when creating a session. " + "For example, to allow all permissions, use " + '{"on_permission_request": PermissionHandler.approve_all}.' + ) tool_defs = [] tools = cfg.get("tools") if tools: for tool in tools: - definition = { + definition: dict[str, Any] = { "name": tool.name, "description": tool.description, } if tool.parameters: definition["parameters"] = tool.parameters + if tool.overrides_built_in_tool: + definition["overridesBuiltInTool"] = True tool_defs.append(definition) payload: dict[str, Any] = {} @@ -484,7 +524,7 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo if available_tools is not None: payload["availableTools"] = available_tools excluded_tools = cfg.get("excluded_tools") - if excluded_tools: + if excluded_tools is not None: payload["excludedTools"] = excluded_tools # Always enable permission request callback (deny by default if no handler provided) @@ -568,8 +608,7 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo workspace_path = response.get("workspacePath") session = CopilotSession(session_id, self._client, workspace_path) session._register_tools(tools) - if on_permission_request: - session._register_permission_handler(on_permission_request) + session._register_permission_handler(on_permission_request) if on_user_input_request: session._register_user_input_handler(on_user_input_request) if hooks: @@ -579,9 +618,7 @@ async def create_session(self, config: Optional[SessionConfig] = None) -> Copilo return session - async def resume_session( - self, session_id: str, config: Optional[ResumeSessionConfig] = None - ) -> CopilotSession: + async def resume_session(self, session_id: str, config: ResumeSessionConfig) -> CopilotSession: """ Resume an existing conversation session by its ID. @@ -601,10 +638,12 @@ async def resume_session( Example: >>> # Resume a previous session - >>> session = await client.resume_session("session-123") + >>> config = {"on_permission_request": PermissionHandler.approve_all} + >>> session = await client.resume_session("session-123", config) >>> >>> # Resume with new tools >>> session = await client.resume_session("session-123", { + ... "on_permission_request": PermissionHandler.approve_all, ... "tools": [my_new_tool] ... }) """ @@ -614,18 +653,27 @@ async def resume_session( else: raise RuntimeError("Client not connected. Call start() first.") - cfg = config or {} + cfg = config + + if not cfg.get("on_permission_request"): + raise ValueError( + "An on_permission_request handler is required when resuming a session. " + "For example, to allow all permissions, use " + '{"on_permission_request": PermissionHandler.approve_all}.' + ) tool_defs = [] tools = cfg.get("tools") if tools: for tool in tools: - definition = { + definition: dict[str, Any] = { "name": tool.name, "description": tool.description, } if tool.parameters: definition["parameters"] = tool.parameters + if tool.overrides_built_in_tool: + definition["overridesBuiltInTool"] = True tool_defs.append(definition) payload: dict[str, Any] = {"sessionId": session_id} @@ -656,7 +704,7 @@ async def resume_session( payload["availableTools"] = available_tools excluded_tools = cfg.get("excluded_tools") - if excluded_tools: + if excluded_tools is not None: payload["excludedTools"] = excluded_tools provider = cfg.get("provider") @@ -744,8 +792,7 @@ async def resume_session( workspace_path = response.get("workspacePath") session = CopilotSession(resumed_session_id, self._client, workspace_path) session._register_tools(cfg.get("tools")) - if on_permission_request: - session._register_permission_handler(on_permission_request) + session._register_permission_handler(on_permission_request) if on_user_input_request: session._register_user_input_handler(on_user_input_request) if hooks: @@ -769,7 +816,7 @@ def get_state(self) -> ConnectionState: """ return self._state - async def ping(self, message: Optional[str] = None) -> "PingResponse": + async def ping(self, message: str | None = None) -> "PingResponse": """ Send a ping request to the server to verify connectivity. @@ -910,10 +957,12 @@ async def list_sessions( async def delete_session(self, session_id: str) -> None: """ - Delete a session permanently. + Permanently delete a session and all its data from disk, including + conversation history, planning state, and artifacts. - This permanently removes the session and all its conversation history. - The session cannot be resumed after deletion. + Unlike :meth:`CopilotSession.disconnect`, which only releases in-memory + resources and preserves session data for later resumption, this method + is irreversible. The session cannot be resumed after deletion. Args: session_id: The ID of the session to delete. @@ -939,7 +988,32 @@ async def delete_session(self, session_id: str) -> None: if session_id in self._sessions: del self._sessions[session_id] - async def get_foreground_session_id(self) -> Optional[str]: + async def get_last_session_id(self) -> str | None: + """ + Get the ID of the most recently updated session. + + This is useful for resuming the last conversation when the session ID + was not stored. + + Returns: + The session ID, or None if no sessions exist. + + Raises: + RuntimeError: If the client is not connected. + + Example: + >>> last_id = await client.get_last_session_id() + >>> if last_id: + ... config = {"on_permission_request": PermissionHandler.approve_all} + ... session = await client.resume_session(last_id, config) + """ + if not self._client: + raise RuntimeError("Client not connected") + + response = await self._client.request("session.getLastId", {}) + return response.get("sessionId") + + async def get_foreground_session_id(self) -> str | None: """ Get the ID of the session currently displayed in the TUI. @@ -992,7 +1066,7 @@ async def set_foreground_session_id(self, session_id: str) -> None: def on( self, event_type_or_handler: SessionLifecycleEventType | SessionLifecycleHandler, - handler: Optional[SessionLifecycleHandler] = None, + handler: SessionLifecycleHandler | None = None, ) -> Callable[[], None]: """ Subscribe to session lifecycle events. @@ -1073,25 +1147,30 @@ def _dispatch_lifecycle_event(self, event: SessionLifecycleEvent) -> None: pass # Ignore handler errors async def _verify_protocol_version(self) -> None: - """Verify that the server's protocol version matches the SDK's expected version.""" - expected_version = get_sdk_protocol_version() + """Verify that the server's protocol version is within the supported range + and store the negotiated version.""" + max_version = get_sdk_protocol_version() ping_result = await self.ping() server_version = ping_result.protocolVersion if server_version is None: raise RuntimeError( - f"SDK protocol version mismatch: SDK expects version {expected_version}, " - f"but server does not report a protocol version. " - f"Please update your server to ensure compatibility." + "SDK protocol version mismatch: " + f"SDK supports versions {MIN_PROTOCOL_VERSION}-{max_version}" + ", but server does not report a protocol version. " + "Please update your server to ensure compatibility." ) - if server_version != expected_version: + if server_version < MIN_PROTOCOL_VERSION or server_version > max_version: raise RuntimeError( - f"SDK protocol version mismatch: SDK expects version {expected_version}, " - f"but server reports version {server_version}. " - f"Please update your SDK or server to ensure compatibility." + "SDK protocol version mismatch: " + f"SDK supports versions {MIN_PROTOCOL_VERSION}-{max_version}" + f", but server reports version {server_version}. " + "Please update your SDK or server to ensure compatibility." ) + self._negotiated_protocol_version = server_version + def _convert_provider_to_wire_format( self, provider: ProviderConfig | dict[str, Any] ) -> dict[str, Any]: @@ -1250,7 +1329,7 @@ async def read_port(): try: await asyncio.wait_for(read_port(), timeout=10.0) - except asyncio.TimeoutError: + except TimeoutError: raise RuntimeError("Timeout waiting for CLI server to start") async def _connect_to_server(self) -> None: @@ -1301,8 +1380,12 @@ def handle_notification(method: str, params: dict): self._dispatch_lifecycle_event(lifecycle_event) self._client.set_notification_handler(handle_notification) - self._client.set_request_handler("tool.call", self._handle_tool_call_request) - self._client.set_request_handler("permission.request", self._handle_permission_request) + # Protocol v3 servers send tool calls / permission requests as broadcast events. + # Protocol v2 servers use the older tool.call / permission.request RPC model. + # We always register v2 adapters because handlers are set up before version + # negotiation; a v3 server will simply never send these requests. + self._client.set_request_handler("tool.call", self._handle_tool_call_request_v2) + self._client.set_request_handler("permission.request", self._handle_permission_request_v2) self._client.set_request_handler("userInput.request", self._handle_user_input_request) self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke) @@ -1382,8 +1465,11 @@ def handle_notification(method: str, params: dict): self._dispatch_lifecycle_event(lifecycle_event) self._client.set_notification_handler(handle_notification) - self._client.set_request_handler("tool.call", self._handle_tool_call_request) - self._client.set_request_handler("permission.request", self._handle_permission_request) + # Protocol v3 servers send tool calls / permission requests as broadcast events. + # Protocol v2 servers use the older tool.call / permission.request RPC model. + # We always register v2 adapters; a v3 server will simply never send these requests. + self._client.set_request_handler("tool.call", self._handle_tool_call_request_v2) + self._client.set_request_handler("permission.request", self._handle_permission_request_v2) self._client.set_request_handler("userInput.request", self._handle_user_input_request) self._client.set_request_handler("hooks.invoke", self._handle_hooks_invoke) @@ -1391,41 +1477,6 @@ def handle_notification(method: str, params: dict): loop = asyncio.get_running_loop() self._client.start(loop) - async def _handle_permission_request(self, params: dict) -> dict: - """ - Handle a permission request from the CLI server. - - Args: - params: The permission request parameters from the server. - - Returns: - A dict containing the permission decision result. - - Raises: - ValueError: If the request payload is invalid. - """ - session_id = params.get("sessionId") - permission_request = params.get("permissionRequest") - - if not session_id or not permission_request: - raise ValueError("invalid permission request payload") - - with self._sessions_lock: - session = self._sessions.get(session_id) - if not session: - raise ValueError(f"unknown session {session_id}") - - try: - result = await session._handle_permission_request(permission_request) - return {"result": result} - except Exception: # pylint: disable=broad-except - # If permission handler fails, deny the permission - return { - "result": { - "kind": "denied-no-approval-rule-and-could-not-request-from-user", - } - } - async def _handle_user_input_request(self, params: dict) -> dict: """ Handle a user input request from the CLI server. @@ -1481,19 +1532,12 @@ async def _handle_hooks_invoke(self, params: dict) -> dict: output = await session._handle_hooks_invoke(hook_type, input_data) return {"output": output} - async def _handle_tool_call_request(self, params: dict) -> dict: - """ - Handle a tool call request from the CLI server. - - Args: - params: The tool call parameters from the server. - - Returns: - A dict containing the tool execution result. + # ======================================================================== + # Protocol v2 backward-compatibility adapters + # ======================================================================== - Raises: - ValueError: If the request payload is invalid or session is unknown. - """ + async def _handle_tool_call_request_v2(self, params: dict) -> dict: + """Handle a v2-style tool.call RPC request from the server.""" session_id = params.get("sessionId") tool_call_id = params.get("toolCallId") tool_name = params.get("toolName") @@ -1508,101 +1552,81 @@ async def _handle_tool_call_request(self, params: dict) -> dict: handler = session._get_tool_handler(tool_name) if not handler: - return {"result": self._build_unsupported_tool_result(tool_name)} + return { + "result": { + "textResultForLlm": ( + f"Tool '{tool_name}' is not supported by this client instance." + ), + "resultType": "failure", + "error": f"tool '{tool_name}' not supported", + "toolTelemetry": {}, + } + } arguments = params.get("arguments") - result = await self._execute_tool_call( - session_id, - tool_call_id, - tool_name, - arguments, - handler, + invocation = ToolInvocation( + session_id=session_id, + tool_call_id=tool_call_id, + tool_name=tool_name, + arguments=arguments, ) - return {"result": result} - - async def _execute_tool_call( - self, - session_id: str, - tool_call_id: str, - tool_name: str, - arguments: Any, - handler: ToolHandler, - ) -> ToolResult: - """ - Execute a tool call with the given handler. - - Args: - session_id: The session ID making the tool call. - tool_call_id: The unique ID for this tool call. - tool_name: The name of the tool being called. - arguments: The arguments to pass to the tool handler. - handler: The tool handler function to execute. - - Returns: - A ToolResult containing the execution result or error. - """ - invocation: ToolInvocation = { - "session_id": session_id, - "tool_call_id": tool_call_id, - "tool_name": tool_name, - "arguments": arguments, - } - try: result = handler(invocation) if inspect.isawaitable(result): result = await result - except Exception as exc: # pylint: disable=broad-except - # Don't expose detailed error information to the LLM for security reasons. - # The actual error is stored in the 'error' field for debugging. - result = ToolResult( - textResultForLlm="Invoking this tool produced an error. " - "Detailed information is not available.", - resultType="failure", - error=str(exc), - toolTelemetry={}, - ) - - if result is None: - result = ToolResult( - textResultForLlm="Tool returned no result.", - resultType="failure", - error="tool returned no result", - toolTelemetry={}, - ) - - return self._normalize_tool_result(result) - - def _normalize_tool_result(self, result: ToolResult) -> ToolResult: - """ - Normalize a tool result for transmission. - - Converts dataclass instances to dictionaries for JSON serialization. - Args: - result: The tool result to normalize. + tool_result: ToolResult = result # type: ignore[assignment] + return { + "result": { + "textResultForLlm": tool_result.text_result_for_llm, + "resultType": tool_result.result_type, + "error": tool_result.error, + "toolTelemetry": tool_result.tool_telemetry or {}, + } + } + except Exception as exc: + return { + "result": { + "textResultForLlm": ( + "Invoking this tool produced an error." + " Detailed information is not available." + ), + "resultType": "failure", + "error": str(exc), + "toolTelemetry": {}, + } + } - Returns: - The normalized tool result. - """ - if is_dataclass(result) and not isinstance(result, type): - return asdict(result) # type: ignore[arg-type] - return result + async def _handle_permission_request_v2(self, params: dict) -> dict: + """Handle a v2-style permission.request RPC request from the server.""" + session_id = params.get("sessionId") + permission_request = params.get("permissionRequest") - def _build_unsupported_tool_result(self, tool_name: str) -> ToolResult: - """ - Build a failure result for an unsupported tool. + if not session_id or not permission_request: + raise ValueError("invalid permission request payload") - Args: - tool_name: The name of the unsupported tool. + with self._sessions_lock: + session = self._sessions.get(session_id) + if not session: + raise ValueError(f"unknown session {session_id}") - Returns: - A ToolResult indicating the tool is not supported. - """ - return ToolResult( - textResultForLlm=f"Tool '{tool_name}' is not supported.", - resultType="failure", - error=f"tool '{tool_name}' not supported", - toolTelemetry={}, - ) + try: + perm_request = PermissionRequest.from_dict(permission_request) + result = await session._handle_permission_request(perm_request) + result_payload: dict = {"kind": result.kind} + if result.rules is not None: + result_payload["rules"] = result.rules + if result.feedback is not None: + result_payload["feedback"] = result.feedback + if result.message is not None: + result_payload["message"] = result.message + if result.path is not None: + result_payload["path"] = result.path + return {"result": result_payload} + except Exception: # pylint: disable=broad-except + return { + "result": { + "kind": "denied-no-approval-rule-and-could-not-request-from-user", + } + } diff --git a/python/copilot/generated/rpc.py b/python/copilot/generated/rpc.py index 3b87bea55..ef188b095 100644 --- a/python/copilot/generated/rpc.py +++ b/python/copilot/generated/rpc.py @@ -10,7 +10,8 @@ from dataclasses import dataclass -from typing import Any, Optional, List, Dict, TypeVar, Type, cast, Callable +from typing import Any, TypeVar, cast +from collections.abc import Callable from enum import Enum @@ -52,22 +53,22 @@ def from_bool(x: Any) -> bool: return x -def to_class(c: Type[T], x: Any) -> dict: +def to_class(c: type[T], x: Any) -> dict: assert isinstance(x, c) return cast(Any, x).to_dict() -def from_list(f: Callable[[Any], T], x: Any) -> List[T]: +def from_list(f: Callable[[Any], T], x: Any) -> list[T]: assert isinstance(x, list) return [f(y) for y in x] -def from_dict(f: Callable[[Any], T], x: Any) -> Dict[str, T]: +def from_dict(f: Callable[[Any], T], x: Any) -> dict[str, T]: assert isinstance(x, dict) return { k: f(v) for (k, v) in x.items() } -def to_enum(c: Type[EnumT], x: Any) -> EnumT: +def to_enum(c: type[EnumT], x: Any) -> EnumT: assert isinstance(x, c) return x.value @@ -101,7 +102,7 @@ def to_dict(self) -> dict: @dataclass class PingParams: - message: Optional[str] = None + message: str | None = None """Optional message to echo back""" @staticmethod @@ -138,8 +139,8 @@ def to_dict(self) -> dict: @dataclass class Limits: max_context_window_tokens: float - max_output_tokens: Optional[float] = None - max_prompt_tokens: Optional[float] = None + max_output_tokens: float | None = None + max_prompt_tokens: float | None = None @staticmethod def from_dict(obj: Any) -> 'Limits': @@ -161,22 +162,24 @@ def to_dict(self) -> dict: @dataclass class Supports: - reasoning_effort: bool + reasoning_effort: bool | None = None """Whether this model supports reasoning effort configuration""" - vision: bool + vision: bool | None = None @staticmethod def from_dict(obj: Any) -> 'Supports': assert isinstance(obj, dict) - reasoning_effort = from_bool(obj.get("reasoningEffort")) - vision = from_bool(obj.get("vision")) + reasoning_effort = from_union([from_bool, from_none], obj.get("reasoningEffort")) + vision = from_union([from_bool, from_none], obj.get("vision")) return Supports(reasoning_effort, vision) def to_dict(self) -> dict: result: dict = {} - result["reasoningEffort"] = from_bool(self.reasoning_effort) - result["vision"] = from_bool(self.vision) + if self.reasoning_effort is not None: + result["reasoningEffort"] = from_union([from_bool, from_none], self.reasoning_effort) + if self.vision is not None: + result["vision"] = from_union([from_bool, from_none], self.vision) return result @@ -233,16 +236,16 @@ class Model: name: str """Display name""" - billing: Optional[Billing] = None + billing: Billing | None = None """Billing information""" - default_reasoning_effort: Optional[str] = None + default_reasoning_effort: str | None = None """Default reasoning effort level (only present if model supports reasoning effort)""" - policy: Optional[Policy] = None + policy: Policy | None = None """Policy state (if applicable)""" - supported_reasoning_efforts: Optional[List[str]] = None + supported_reasoning_efforts: list[str] | None = None """Supported reasoning effort levels (only present if model supports reasoning effort)""" @staticmethod @@ -275,7 +278,7 @@ def to_dict(self) -> dict: @dataclass class ModelsListResult: - models: List[Model] + models: list[Model] """List of available models with full metadata""" @staticmethod @@ -298,14 +301,14 @@ class Tool: name: str """Tool identifier (e.g., "bash", "grep", "str_replace_editor")""" - instructions: Optional[str] = None + instructions: str | None = None """Optional instructions for how to use this tool effectively""" - namespaced_name: Optional[str] = None + namespaced_name: str | None = None """Optional namespaced name for declarative filtering (e.g., "playwright/navigate" for MCP tools) """ - parameters: Optional[Dict[str, Any]] = None + parameters: dict[str, Any] | None = None """JSON Schema for the tool's input parameters""" @staticmethod @@ -333,7 +336,7 @@ def to_dict(self) -> dict: @dataclass class ToolsListResult: - tools: List[Tool] + tools: list[Tool] """List of available built-in tools with metadata""" @staticmethod @@ -350,7 +353,7 @@ def to_dict(self) -> dict: @dataclass class ToolsListParams: - model: Optional[str] = None + model: str | None = None """Optional model ID — when provided, the returned tool list reflects model-specific overrides """ @@ -385,7 +388,7 @@ class QuotaSnapshot: used_requests: float """Number of requests used so far this period""" - reset_date: Optional[str] = None + reset_date: str | None = None """Date when the quota resets (ISO 8601)""" @staticmethod @@ -413,7 +416,7 @@ def to_dict(self) -> dict: @dataclass class AccountGetQuotaResult: - quota_snapshots: Dict[str, QuotaSnapshot] + quota_snapshots: dict[str, QuotaSnapshot] """Quota snapshots keyed by type (e.g., chat, completions, premium_interactions)""" @staticmethod @@ -430,7 +433,7 @@ def to_dict(self) -> dict: @dataclass class SessionModelGetCurrentResult: - model_id: Optional[str] = None + model_id: str | None = None @staticmethod def from_dict(obj: Any) -> 'SessionModelGetCurrentResult': @@ -447,7 +450,7 @@ def to_dict(self) -> dict: @dataclass class SessionModelSwitchToResult: - model_id: Optional[str] = None + model_id: str | None = None @staticmethod def from_dict(obj: Any) -> 'SessionModelSwitchToResult': @@ -544,22 +547,27 @@ def to_dict(self) -> dict: @dataclass class SessionPlanReadResult: exists: bool - """Whether plan.md exists in the workspace""" + """Whether the plan file exists in the workspace""" - content: Optional[str] = None - """The content of plan.md, or null if it does not exist""" + content: str | None = None + """The content of the plan file, or null if it does not exist""" + + path: str | None = None + """Absolute file path of the plan file, or null if workspace is not enabled""" @staticmethod def from_dict(obj: Any) -> 'SessionPlanReadResult': assert isinstance(obj, dict) exists = from_bool(obj.get("exists")) content = from_union([from_none, from_str], obj.get("content")) - return SessionPlanReadResult(exists, content) + path = from_union([from_none, from_str], obj.get("path")) + return SessionPlanReadResult(exists, content, path) def to_dict(self) -> dict: result: dict = {} result["exists"] = from_bool(self.exists) result["content"] = from_union([from_none, from_str], self.content) + result["path"] = from_union([from_none, from_str], self.path) return result @@ -578,7 +586,7 @@ def to_dict(self) -> dict: @dataclass class SessionPlanUpdateParams: content: str - """The new content for plan.md""" + """The new content for the plan file""" @staticmethod def from_dict(obj: Any) -> 'SessionPlanUpdateParams': @@ -606,7 +614,7 @@ def to_dict(self) -> dict: @dataclass class SessionWorkspaceListFilesResult: - files: List[str] + files: list[str] """Relative file paths in the workspace files directory""" @staticmethod @@ -708,7 +716,7 @@ def to_dict(self) -> dict: @dataclass class SessionFleetStartParams: - prompt: Optional[str] = None + prompt: str | None = None """Optional user prompt to combine with fleet instructions""" @staticmethod @@ -724,6 +732,339 @@ def to_dict(self) -> dict: return result +@dataclass +class AgentElement: + description: str + """Description of the agent's purpose""" + + display_name: str + """Human-readable display name""" + + name: str + """Unique identifier of the custom agent""" + + @staticmethod + def from_dict(obj: Any) -> 'AgentElement': + assert isinstance(obj, dict) + description = from_str(obj.get("description")) + display_name = from_str(obj.get("displayName")) + name = from_str(obj.get("name")) + return AgentElement(description, display_name, name) + + def to_dict(self) -> dict: + result: dict = {} + result["description"] = from_str(self.description) + result["displayName"] = from_str(self.display_name) + result["name"] = from_str(self.name) + return result + + +@dataclass +class SessionAgentListResult: + agents: list[AgentElement] + """Available custom agents""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentListResult': + assert isinstance(obj, dict) + agents = from_list(AgentElement.from_dict, obj.get("agents")) + return SessionAgentListResult(agents) + + def to_dict(self) -> dict: + result: dict = {} + result["agents"] = from_list(lambda x: to_class(AgentElement, x), self.agents) + return result + + +@dataclass +class SessionAgentGetCurrentResultAgent: + description: str + """Description of the agent's purpose""" + + display_name: str + """Human-readable display name""" + + name: str + """Unique identifier of the custom agent""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentGetCurrentResultAgent': + assert isinstance(obj, dict) + description = from_str(obj.get("description")) + display_name = from_str(obj.get("displayName")) + name = from_str(obj.get("name")) + return SessionAgentGetCurrentResultAgent(description, display_name, name) + + def to_dict(self) -> dict: + result: dict = {} + result["description"] = from_str(self.description) + result["displayName"] = from_str(self.display_name) + result["name"] = from_str(self.name) + return result + + +@dataclass +class SessionAgentGetCurrentResult: + agent: SessionAgentGetCurrentResultAgent | None = None + """Currently selected custom agent, or null if using the default agent""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentGetCurrentResult': + assert isinstance(obj, dict) + agent = from_union([SessionAgentGetCurrentResultAgent.from_dict, from_none], obj.get("agent")) + return SessionAgentGetCurrentResult(agent) + + def to_dict(self) -> dict: + result: dict = {} + result["agent"] = from_union([lambda x: to_class(SessionAgentGetCurrentResultAgent, x), from_none], self.agent) + return result + + +@dataclass +class SessionAgentSelectResultAgent: + """The newly selected custom agent""" + + description: str + """Description of the agent's purpose""" + + display_name: str + """Human-readable display name""" + + name: str + """Unique identifier of the custom agent""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentSelectResultAgent': + assert isinstance(obj, dict) + description = from_str(obj.get("description")) + display_name = from_str(obj.get("displayName")) + name = from_str(obj.get("name")) + return SessionAgentSelectResultAgent(description, display_name, name) + + def to_dict(self) -> dict: + result: dict = {} + result["description"] = from_str(self.description) + result["displayName"] = from_str(self.display_name) + result["name"] = from_str(self.name) + return result + + +@dataclass +class SessionAgentSelectResult: + agent: SessionAgentSelectResultAgent + """The newly selected custom agent""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentSelectResult': + assert isinstance(obj, dict) + agent = SessionAgentSelectResultAgent.from_dict(obj.get("agent")) + return SessionAgentSelectResult(agent) + + def to_dict(self) -> dict: + result: dict = {} + result["agent"] = to_class(SessionAgentSelectResultAgent, self.agent) + return result + + +@dataclass +class SessionAgentSelectParams: + name: str + """Name of the custom agent to select""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentSelectParams': + assert isinstance(obj, dict) + name = from_str(obj.get("name")) + return SessionAgentSelectParams(name) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + return result + + +@dataclass +class SessionAgentDeselectResult: + @staticmethod + def from_dict(obj: Any) -> 'SessionAgentDeselectResult': + assert isinstance(obj, dict) + return SessionAgentDeselectResult() + + def to_dict(self) -> dict: + result: dict = {} + return result + + +@dataclass +class SessionCompactionCompactResult: + messages_removed: float + """Number of messages removed during compaction""" + + success: bool + """Whether compaction completed successfully""" + + tokens_removed: float + """Number of tokens freed by compaction""" + + @staticmethod + def from_dict(obj: Any) -> 'SessionCompactionCompactResult': + assert isinstance(obj, dict) + messages_removed = from_float(obj.get("messagesRemoved")) + success = from_bool(obj.get("success")) + tokens_removed = from_float(obj.get("tokensRemoved")) + return SessionCompactionCompactResult(messages_removed, success, tokens_removed) + + def to_dict(self) -> dict: + result: dict = {} + result["messagesRemoved"] = to_float(self.messages_removed) + result["success"] = from_bool(self.success) + result["tokensRemoved"] = to_float(self.tokens_removed) + return result + + +@dataclass +class SessionToolsHandlePendingToolCallResult: + success: bool + + @staticmethod + def from_dict(obj: Any) -> 'SessionToolsHandlePendingToolCallResult': + assert isinstance(obj, dict) + success = from_bool(obj.get("success")) + return SessionToolsHandlePendingToolCallResult(success) + + def to_dict(self) -> dict: + result: dict = {} + result["success"] = from_bool(self.success) + return result + + +@dataclass +class ResultResult: + text_result_for_llm: str + error: str | None = None + result_type: str | None = None + tool_telemetry: dict[str, Any] | None = None + + @staticmethod + def from_dict(obj: Any) -> 'ResultResult': + assert isinstance(obj, dict) + text_result_for_llm = from_str(obj.get("textResultForLlm")) + error = from_union([from_str, from_none], obj.get("error")) + result_type = from_union([from_str, from_none], obj.get("resultType")) + tool_telemetry = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("toolTelemetry")) + return ResultResult(text_result_for_llm, error, result_type, tool_telemetry) + + def to_dict(self) -> dict: + result: dict = {} + result["textResultForLlm"] = from_str(self.text_result_for_llm) + if self.error is not None: + result["error"] = from_union([from_str, from_none], self.error) + if self.result_type is not None: + result["resultType"] = from_union([from_str, from_none], self.result_type) + if self.tool_telemetry is not None: + result["toolTelemetry"] = from_union([lambda x: from_dict(lambda x: x, x), from_none], self.tool_telemetry) + return result + + +@dataclass +class SessionToolsHandlePendingToolCallParams: + request_id: str + error: str | None = None + result: ResultResult | str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'SessionToolsHandlePendingToolCallParams': + assert isinstance(obj, dict) + request_id = from_str(obj.get("requestId")) + error = from_union([from_str, from_none], obj.get("error")) + result = from_union([ResultResult.from_dict, from_str, from_none], obj.get("result")) + return SessionToolsHandlePendingToolCallParams(request_id, error, result) + + def to_dict(self) -> dict: + result: dict = {} + result["requestId"] = from_str(self.request_id) + if self.error is not None: + result["error"] = from_union([from_str, from_none], self.error) + if self.result is not None: + result["result"] = from_union([lambda x: to_class(ResultResult, x), from_str, from_none], self.result) + return result + + +@dataclass +class SessionPermissionsHandlePendingPermissionRequestResult: + success: bool + + @staticmethod + def from_dict(obj: Any) -> 'SessionPermissionsHandlePendingPermissionRequestResult': + assert isinstance(obj, dict) + success = from_bool(obj.get("success")) + return SessionPermissionsHandlePendingPermissionRequestResult(success) + + def to_dict(self) -> dict: + result: dict = {} + result["success"] = from_bool(self.success) + return result + + +class Kind(Enum): + APPROVED = "approved" + DENIED_BY_CONTENT_EXCLUSION_POLICY = "denied-by-content-exclusion-policy" + DENIED_BY_RULES = "denied-by-rules" + DENIED_INTERACTIVELY_BY_USER = "denied-interactively-by-user" + DENIED_NO_APPROVAL_RULE_AND_COULD_NOT_REQUEST_FROM_USER = "denied-no-approval-rule-and-could-not-request-from-user" + + +@dataclass +class SessionPermissionsHandlePendingPermissionRequestParamsResult: + kind: Kind + rules: list[Any] | None = None + feedback: str | None = None + message: str | None = None + path: str | None = None + + @staticmethod + def from_dict(obj: Any) -> 'SessionPermissionsHandlePendingPermissionRequestParamsResult': + assert isinstance(obj, dict) + kind = Kind(obj.get("kind")) + rules = from_union([lambda x: from_list(lambda x: x, x), from_none], obj.get("rules")) + feedback = from_union([from_str, from_none], obj.get("feedback")) + message = from_union([from_str, from_none], obj.get("message")) + path = from_union([from_str, from_none], obj.get("path")) + return SessionPermissionsHandlePendingPermissionRequestParamsResult(kind, rules, feedback, message, path) + + def to_dict(self) -> dict: + result: dict = {} + result["kind"] = to_enum(Kind, self.kind) + if self.rules is not None: + result["rules"] = from_union([lambda x: from_list(lambda x: x, x), from_none], self.rules) + if self.feedback is not None: + result["feedback"] = from_union([from_str, from_none], self.feedback) + if self.message is not None: + result["message"] = from_union([from_str, from_none], self.message) + if self.path is not None: + result["path"] = from_union([from_str, from_none], self.path) + return result + + +@dataclass +class SessionPermissionsHandlePendingPermissionRequestParams: + request_id: str + result: SessionPermissionsHandlePendingPermissionRequestParamsResult + + @staticmethod + def from_dict(obj: Any) -> 'SessionPermissionsHandlePendingPermissionRequestParams': + assert isinstance(obj, dict) + request_id = from_str(obj.get("requestId")) + result = SessionPermissionsHandlePendingPermissionRequestParamsResult.from_dict(obj.get("result")) + return SessionPermissionsHandlePendingPermissionRequestParams(request_id, result) + + def to_dict(self) -> dict: + result: dict = {} + result["requestId"] = from_str(self.request_id) + result["result"] = to_class(SessionPermissionsHandlePendingPermissionRequestParamsResult, self.result) + return result + + def ping_result_from_dict(s: Any) -> PingResult: return PingResult.from_dict(s) @@ -908,42 +1249,129 @@ def session_fleet_start_params_to_dict(x: SessionFleetStartParams) -> Any: return to_class(SessionFleetStartParams, x) -class ModelsApi: +def session_agent_list_result_from_dict(s: Any) -> SessionAgentListResult: + return SessionAgentListResult.from_dict(s) + + +def session_agent_list_result_to_dict(x: SessionAgentListResult) -> Any: + return to_class(SessionAgentListResult, x) + + +def session_agent_get_current_result_from_dict(s: Any) -> SessionAgentGetCurrentResult: + return SessionAgentGetCurrentResult.from_dict(s) + + +def session_agent_get_current_result_to_dict(x: SessionAgentGetCurrentResult) -> Any: + return to_class(SessionAgentGetCurrentResult, x) + + +def session_agent_select_result_from_dict(s: Any) -> SessionAgentSelectResult: + return SessionAgentSelectResult.from_dict(s) + + +def session_agent_select_result_to_dict(x: SessionAgentSelectResult) -> Any: + return to_class(SessionAgentSelectResult, x) + + +def session_agent_select_params_from_dict(s: Any) -> SessionAgentSelectParams: + return SessionAgentSelectParams.from_dict(s) + + +def session_agent_select_params_to_dict(x: SessionAgentSelectParams) -> Any: + return to_class(SessionAgentSelectParams, x) + + +def session_agent_deselect_result_from_dict(s: Any) -> SessionAgentDeselectResult: + return SessionAgentDeselectResult.from_dict(s) + + +def session_agent_deselect_result_to_dict(x: SessionAgentDeselectResult) -> Any: + return to_class(SessionAgentDeselectResult, x) + + +def session_compaction_compact_result_from_dict(s: Any) -> SessionCompactionCompactResult: + return SessionCompactionCompactResult.from_dict(s) + + +def session_compaction_compact_result_to_dict(x: SessionCompactionCompactResult) -> Any: + return to_class(SessionCompactionCompactResult, x) + + +def session_tools_handle_pending_tool_call_result_from_dict(s: Any) -> SessionToolsHandlePendingToolCallResult: + return SessionToolsHandlePendingToolCallResult.from_dict(s) + + +def session_tools_handle_pending_tool_call_result_to_dict(x: SessionToolsHandlePendingToolCallResult) -> Any: + return to_class(SessionToolsHandlePendingToolCallResult, x) + + +def session_tools_handle_pending_tool_call_params_from_dict(s: Any) -> SessionToolsHandlePendingToolCallParams: + return SessionToolsHandlePendingToolCallParams.from_dict(s) + + +def session_tools_handle_pending_tool_call_params_to_dict(x: SessionToolsHandlePendingToolCallParams) -> Any: + return to_class(SessionToolsHandlePendingToolCallParams, x) + + +def session_permissions_handle_pending_permission_request_result_from_dict(s: Any) -> SessionPermissionsHandlePendingPermissionRequestResult: + return SessionPermissionsHandlePendingPermissionRequestResult.from_dict(s) + + +def session_permissions_handle_pending_permission_request_result_to_dict(x: SessionPermissionsHandlePendingPermissionRequestResult) -> Any: + return to_class(SessionPermissionsHandlePendingPermissionRequestResult, x) + + +def session_permissions_handle_pending_permission_request_params_from_dict(s: Any) -> SessionPermissionsHandlePendingPermissionRequestParams: + return SessionPermissionsHandlePendingPermissionRequestParams.from_dict(s) + + +def session_permissions_handle_pending_permission_request_params_to_dict(x: SessionPermissionsHandlePendingPermissionRequestParams) -> Any: + return to_class(SessionPermissionsHandlePendingPermissionRequestParams, x) + + +def _timeout_kwargs(timeout: float | None) -> dict: + """Build keyword arguments for optional timeout forwarding.""" + if timeout is not None: + return {"timeout": timeout} + return {} + + +class ServerModelsApi: def __init__(self, client: "JsonRpcClient"): self._client = client - async def list(self) -> ModelsListResult: - return ModelsListResult.from_dict(await self._client.request("models.list", {})) + async def list(self, *, timeout: float | None = None) -> ModelsListResult: + return ModelsListResult.from_dict(await self._client.request("models.list", {}, **_timeout_kwargs(timeout))) -class ToolsApi: +class ServerToolsApi: def __init__(self, client: "JsonRpcClient"): self._client = client - async def list(self, params: ToolsListParams) -> ToolsListResult: + async def list(self, params: ToolsListParams, *, timeout: float | None = None) -> ToolsListResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} - return ToolsListResult.from_dict(await self._client.request("tools.list", params_dict)) + return ToolsListResult.from_dict(await self._client.request("tools.list", params_dict, **_timeout_kwargs(timeout))) -class AccountApi: +class ServerAccountApi: def __init__(self, client: "JsonRpcClient"): self._client = client - async def get_quota(self) -> AccountGetQuotaResult: - return AccountGetQuotaResult.from_dict(await self._client.request("account.getQuota", {})) + async def get_quota(self, *, timeout: float | None = None) -> AccountGetQuotaResult: + return AccountGetQuotaResult.from_dict(await self._client.request("account.getQuota", {}, **_timeout_kwargs(timeout))) class ServerRpc: """Typed server-scoped RPC methods.""" def __init__(self, client: "JsonRpcClient"): self._client = client - self.models = ModelsApi(client) - self.tools = ToolsApi(client) - self.account = AccountApi(client) + self.models = ServerModelsApi(client) + self.tools = ServerToolsApi(client) + self.account = ServerAccountApi(client) - async def ping(self, params: PingParams) -> PingResult: + async def ping(self, params: PingParams, *, timeout: float | None = None) -> PingResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} - return PingResult.from_dict(await self._client.request("ping", params_dict)) + return PingResult.from_dict(await self._client.request("ping", params_dict, **_timeout_kwargs(timeout))) class ModelApi: @@ -951,13 +1379,13 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client self._session_id = session_id - async def get_current(self) -> SessionModelGetCurrentResult: - return SessionModelGetCurrentResult.from_dict(await self._client.request("session.model.getCurrent", {"sessionId": self._session_id})) + async def get_current(self, *, timeout: float | None = None) -> SessionModelGetCurrentResult: + return SessionModelGetCurrentResult.from_dict(await self._client.request("session.model.getCurrent", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) - async def switch_to(self, params: SessionModelSwitchToParams) -> SessionModelSwitchToResult: + async def switch_to(self, params: SessionModelSwitchToParams, *, timeout: float | None = None) -> SessionModelSwitchToResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id - return SessionModelSwitchToResult.from_dict(await self._client.request("session.model.switchTo", params_dict)) + return SessionModelSwitchToResult.from_dict(await self._client.request("session.model.switchTo", params_dict, **_timeout_kwargs(timeout))) class ModeApi: @@ -965,13 +1393,13 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client self._session_id = session_id - async def get(self) -> SessionModeGetResult: - return SessionModeGetResult.from_dict(await self._client.request("session.mode.get", {"sessionId": self._session_id})) + async def get(self, *, timeout: float | None = None) -> SessionModeGetResult: + return SessionModeGetResult.from_dict(await self._client.request("session.mode.get", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) - async def set(self, params: SessionModeSetParams) -> SessionModeSetResult: + async def set(self, params: SessionModeSetParams, *, timeout: float | None = None) -> SessionModeSetResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id - return SessionModeSetResult.from_dict(await self._client.request("session.mode.set", params_dict)) + return SessionModeSetResult.from_dict(await self._client.request("session.mode.set", params_dict, **_timeout_kwargs(timeout))) class PlanApi: @@ -979,16 +1407,16 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client self._session_id = session_id - async def read(self) -> SessionPlanReadResult: - return SessionPlanReadResult.from_dict(await self._client.request("session.plan.read", {"sessionId": self._session_id})) + async def read(self, *, timeout: float | None = None) -> SessionPlanReadResult: + return SessionPlanReadResult.from_dict(await self._client.request("session.plan.read", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) - async def update(self, params: SessionPlanUpdateParams) -> SessionPlanUpdateResult: + async def update(self, params: SessionPlanUpdateParams, *, timeout: float | None = None) -> SessionPlanUpdateResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id - return SessionPlanUpdateResult.from_dict(await self._client.request("session.plan.update", params_dict)) + return SessionPlanUpdateResult.from_dict(await self._client.request("session.plan.update", params_dict, **_timeout_kwargs(timeout))) - async def delete(self) -> SessionPlanDeleteResult: - return SessionPlanDeleteResult.from_dict(await self._client.request("session.plan.delete", {"sessionId": self._session_id})) + async def delete(self, *, timeout: float | None = None) -> SessionPlanDeleteResult: + return SessionPlanDeleteResult.from_dict(await self._client.request("session.plan.delete", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) class WorkspaceApi: @@ -996,18 +1424,18 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client self._session_id = session_id - async def list_files(self) -> SessionWorkspaceListFilesResult: - return SessionWorkspaceListFilesResult.from_dict(await self._client.request("session.workspace.listFiles", {"sessionId": self._session_id})) + async def list_files(self, *, timeout: float | None = None) -> SessionWorkspaceListFilesResult: + return SessionWorkspaceListFilesResult.from_dict(await self._client.request("session.workspace.listFiles", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) - async def read_file(self, params: SessionWorkspaceReadFileParams) -> SessionWorkspaceReadFileResult: + async def read_file(self, params: SessionWorkspaceReadFileParams, *, timeout: float | None = None) -> SessionWorkspaceReadFileResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id - return SessionWorkspaceReadFileResult.from_dict(await self._client.request("session.workspace.readFile", params_dict)) + return SessionWorkspaceReadFileResult.from_dict(await self._client.request("session.workspace.readFile", params_dict, **_timeout_kwargs(timeout))) - async def create_file(self, params: SessionWorkspaceCreateFileParams) -> SessionWorkspaceCreateFileResult: + async def create_file(self, params: SessionWorkspaceCreateFileParams, *, timeout: float | None = None) -> SessionWorkspaceCreateFileResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id - return SessionWorkspaceCreateFileResult.from_dict(await self._client.request("session.workspace.createFile", params_dict)) + return SessionWorkspaceCreateFileResult.from_dict(await self._client.request("session.workspace.createFile", params_dict, **_timeout_kwargs(timeout))) class FleetApi: @@ -1015,10 +1443,61 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self._client = client self._session_id = session_id - async def start(self, params: SessionFleetStartParams) -> SessionFleetStartResult: + async def start(self, params: SessionFleetStartParams, *, timeout: float | None = None) -> SessionFleetStartResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionFleetStartResult.from_dict(await self._client.request("session.fleet.start", params_dict, **_timeout_kwargs(timeout))) + + +class AgentApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def list(self, *, timeout: float | None = None) -> SessionAgentListResult: + return SessionAgentListResult.from_dict(await self._client.request("session.agent.list", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + + async def get_current(self, *, timeout: float | None = None) -> SessionAgentGetCurrentResult: + return SessionAgentGetCurrentResult.from_dict(await self._client.request("session.agent.getCurrent", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + + async def select(self, params: SessionAgentSelectParams, *, timeout: float | None = None) -> SessionAgentSelectResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionAgentSelectResult.from_dict(await self._client.request("session.agent.select", params_dict, **_timeout_kwargs(timeout))) + + async def deselect(self, *, timeout: float | None = None) -> SessionAgentDeselectResult: + return SessionAgentDeselectResult.from_dict(await self._client.request("session.agent.deselect", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + + +class CompactionApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def compact(self, *, timeout: float | None = None) -> SessionCompactionCompactResult: + return SessionCompactionCompactResult.from_dict(await self._client.request("session.compaction.compact", {"sessionId": self._session_id}, **_timeout_kwargs(timeout))) + + +class ToolsApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def handle_pending_tool_call(self, params: SessionToolsHandlePendingToolCallParams, *, timeout: float | None = None) -> SessionToolsHandlePendingToolCallResult: + params_dict = {k: v for k, v in params.to_dict().items() if v is not None} + params_dict["sessionId"] = self._session_id + return SessionToolsHandlePendingToolCallResult.from_dict(await self._client.request("session.tools.handlePendingToolCall", params_dict, **_timeout_kwargs(timeout))) + + +class PermissionsApi: + def __init__(self, client: "JsonRpcClient", session_id: str): + self._client = client + self._session_id = session_id + + async def handle_pending_permission_request(self, params: SessionPermissionsHandlePendingPermissionRequestParams, *, timeout: float | None = None) -> SessionPermissionsHandlePendingPermissionRequestResult: params_dict = {k: v for k, v in params.to_dict().items() if v is not None} params_dict["sessionId"] = self._session_id - return SessionFleetStartResult.from_dict(await self._client.request("session.fleet.start", params_dict)) + return SessionPermissionsHandlePendingPermissionRequestResult.from_dict(await self._client.request("session.permissions.handlePendingPermissionRequest", params_dict, **_timeout_kwargs(timeout))) class SessionRpc: @@ -1031,4 +1510,8 @@ def __init__(self, client: "JsonRpcClient", session_id: str): self.plan = PlanApi(client, session_id) self.workspace = WorkspaceApi(client, session_id) self.fleet = FleetApi(client, session_id) + self.agent = AgentApi(client, session_id) + self.compaction = CompactionApi(client, session_id) + self.tools = ToolsApi(client, session_id) + self.permissions = PermissionsApi(client, session_id) diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 0d588058a..1b442530d 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -5,7 +5,8 @@ from enum import Enum from dataclasses import dataclass -from typing import Any, Optional, List, Dict, Union, TypeVar, Type, cast, Callable +from typing import Any, TypeVar, cast +from collections.abc import Callable from datetime import datetime from uuid import UUID import dateutil.parser @@ -25,7 +26,7 @@ def to_float(x: Any) -> float: return x -def to_class(c: Type[T], x: Any) -> dict: +def to_class(c: type[T], x: Any) -> dict: assert isinstance(x, c) return cast(Any, x).to_dict() @@ -49,17 +50,17 @@ def from_union(fs, x): assert False -def to_enum(c: Type[EnumT], x: Any) -> EnumT: +def to_enum(c: type[EnumT], x: Any) -> EnumT: assert isinstance(x, c) return x.value -def from_list(f: Callable[[Any], T], x: Any) -> List[T]: +def from_list(f: Callable[[Any], T], x: Any) -> list[T]: assert isinstance(x, list) return [f(y) for y in x] -def from_dict(f: Callable[[Any], T], x: Any) -> Dict[str, T]: +def from_dict(f: Callable[[Any], T], x: Any) -> dict[str, T]: assert isinstance(x, dict) return { k: f(v) for (k, v) in x.items() } @@ -79,6 +80,8 @@ def from_int(x: Any) -> int: class AgentMode(Enum): + """The agent mode that was active when this message was sent""" + AUTOPILOT = "autopilot" INTERACTIVE = "interactive" PLAN = "plan" @@ -87,8 +90,13 @@ class AgentMode(Enum): @dataclass class LineRange: + """Optional line range to scope the attachment to a specific section of the file""" + end: float + """End line number (1-based, inclusive)""" + start: float + """Start line number (1-based)""" @staticmethod def from_dict(obj: Any) -> 'LineRange': @@ -104,10 +112,21 @@ def to_dict(self) -> dict: return result +class ReferenceType(Enum): + """Type of GitHub reference""" + + DISCUSSION = "discussion" + ISSUE = "issue" + PR = "pr" + + @dataclass class End: character: float + """End character offset within the line (0-based)""" + line: float + """End line number (0-based)""" @staticmethod def from_dict(obj: Any) -> 'End': @@ -126,7 +145,10 @@ def to_dict(self) -> dict: @dataclass class Start: character: float + """Start character offset within the line (0-based)""" + line: float + """Start line number (0-based)""" @staticmethod def from_dict(obj: Any) -> 'Start': @@ -144,6 +166,8 @@ def to_dict(self) -> dict: @dataclass class Selection: + """Position range of the selection within the file""" + end: End start: Start @@ -164,35 +188,72 @@ def to_dict(self) -> dict: class AttachmentType(Enum): DIRECTORY = "directory" FILE = "file" + GITHUB_REFERENCE = "github_reference" SELECTION = "selection" @dataclass class Attachment: - display_name: str type: AttachmentType - line_range: Optional[LineRange] = None - path: Optional[str] = None - file_path: Optional[str] = None - selection: Optional[Selection] = None - text: Optional[str] = None + """Attachment type discriminator""" + + display_name: str | None = None + """User-facing display name for the attachment + + User-facing display name for the selection + """ + line_range: LineRange | None = None + """Optional line range to scope the attachment to a specific section of the file""" + + path: str | None = None + """Absolute file or directory path""" + + file_path: str | None = None + """Absolute path to the file containing the selection""" + + selection: Selection | None = None + """Position range of the selection within the file""" + + text: str | None = None + """The selected text content""" + + number: float | None = None + """Issue, pull request, or discussion number""" + + reference_type: ReferenceType | None = None + """Type of GitHub reference""" + + state: str | None = None + """Current state of the referenced item (e.g., open, closed, merged)""" + + title: str | None = None + """Title of the referenced item""" + + url: str | None = None + """URL to the referenced item on GitHub""" @staticmethod def from_dict(obj: Any) -> 'Attachment': assert isinstance(obj, dict) - display_name = from_str(obj.get("displayName")) type = AttachmentType(obj.get("type")) + display_name = from_union([from_str, from_none], obj.get("displayName")) line_range = from_union([LineRange.from_dict, from_none], obj.get("lineRange")) path = from_union([from_str, from_none], obj.get("path")) file_path = from_union([from_str, from_none], obj.get("filePath")) selection = from_union([Selection.from_dict, from_none], obj.get("selection")) text = from_union([from_str, from_none], obj.get("text")) - return Attachment(display_name, type, line_range, path, file_path, selection, text) + number = from_union([from_float, from_none], obj.get("number")) + reference_type = from_union([ReferenceType, from_none], obj.get("referenceType")) + state = from_union([from_str, from_none], obj.get("state")) + title = from_union([from_str, from_none], obj.get("title")) + url = from_union([from_str, from_none], obj.get("url")) + return Attachment(type, display_name, line_range, path, file_path, selection, text, number, reference_type, state, title, url) def to_dict(self) -> dict: result: dict = {} - result["displayName"] = from_str(self.display_name) result["type"] = to_enum(AttachmentType, self.type) + if self.display_name is not None: + result["displayName"] = from_union([from_str, from_none], self.display_name) if self.line_range is not None: result["lineRange"] = from_union([lambda x: to_class(LineRange, x), from_none], self.line_range) if self.path is not None: @@ -203,14 +264,106 @@ def to_dict(self) -> dict: result["selection"] = from_union([lambda x: to_class(Selection, x), from_none], self.selection) if self.text is not None: result["text"] = from_union([from_str, from_none], self.text) + if self.number is not None: + result["number"] = from_union([to_float, from_none], self.number) + if self.reference_type is not None: + result["referenceType"] = from_union([lambda x: to_enum(ReferenceType, x), from_none], self.reference_type) + if self.state is not None: + result["state"] = from_union([from_str, from_none], self.state) + if self.title is not None: + result["title"] = from_union([from_str, from_none], self.title) + if self.url is not None: + result["url"] = from_union([from_str, from_none], self.url) + return result + + +@dataclass +class Agent: + agent_id: str + """Unique identifier of the background agent""" + + agent_type: str + """Type of the background agent""" + + description: str | None = None + """Human-readable description of the agent task""" + + @staticmethod + def from_dict(obj: Any) -> 'Agent': + assert isinstance(obj, dict) + agent_id = from_str(obj.get("agentId")) + agent_type = from_str(obj.get("agentType")) + description = from_union([from_str, from_none], obj.get("description")) + return Agent(agent_id, agent_type, description) + + def to_dict(self) -> dict: + result: dict = {} + result["agentId"] = from_str(self.agent_id) + result["agentType"] = from_str(self.agent_type) + if self.description is not None: + result["description"] = from_union([from_str, from_none], self.description) + return result + + +@dataclass +class Shell: + shell_id: str + """Unique identifier of the background shell""" + + description: str | None = None + """Human-readable description of the shell command""" + + @staticmethod + def from_dict(obj: Any) -> 'Shell': + assert isinstance(obj, dict) + shell_id = from_str(obj.get("shellId")) + description = from_union([from_str, from_none], obj.get("description")) + return Shell(shell_id, description) + + def to_dict(self) -> dict: + result: dict = {} + result["shellId"] = from_str(self.shell_id) + if self.description is not None: + result["description"] = from_union([from_str, from_none], self.description) + return result + + +@dataclass +class BackgroundTasks: + """Background tasks still running when the agent became idle""" + + agents: list[Agent] + """Currently running background agents""" + + shells: list[Shell] + """Currently running background shell commands""" + + @staticmethod + def from_dict(obj: Any) -> 'BackgroundTasks': + assert isinstance(obj, dict) + agents = from_list(Agent.from_dict, obj.get("agents")) + shells = from_list(Shell.from_dict, obj.get("shells")) + return BackgroundTasks(agents, shells) + + def to_dict(self) -> dict: + result: dict = {} + result["agents"] = from_list(lambda x: to_class(Agent, x), self.agents) + result["shells"] = from_list(lambda x: to_class(Shell, x), self.shells) return result @dataclass class CodeChanges: - files_modified: List[str] + """Aggregate code change metrics for the session""" + + files_modified: list[str] + """List of file paths that were modified during the session""" + lines_added: float + """Total number of lines added during the session""" + lines_removed: float + """Total number of lines removed during the session""" @staticmethod def from_dict(obj: Any) -> 'CodeChanges': @@ -230,9 +383,16 @@ def to_dict(self) -> dict: @dataclass class CompactionTokensUsed: + """Token usage breakdown for the compaction LLM call""" + cached_input: float + """Cached input tokens reused in the compaction LLM call""" + input: float + """Input tokens consumed by the compaction LLM call""" + output: float + """Output tokens produced by the compaction LLM call""" @staticmethod def from_dict(obj: Any) -> 'CompactionTokensUsed': @@ -252,10 +412,21 @@ def to_dict(self) -> dict: @dataclass class ContextClass: + """Working directory and git context at session start + + Updated working directory and git context at resume time + """ cwd: str - branch: Optional[str] = None - git_root: Optional[str] = None - repository: Optional[str] = None + """Current working directory path""" + + branch: str | None = None + """Current git branch name""" + + git_root: str | None = None + """Root directory of the git repository, resolved via git rev-parse""" + + repository: str | None = None + """Repository identifier in "owner/name" format, derived from the git remote URL""" @staticmethod def from_dict(obj: Any) -> 'ContextClass': @@ -278,11 +449,76 @@ def to_dict(self) -> dict: return result +@dataclass +class TokenDetail: + batch_size: float + """Number of tokens in this billing batch""" + + cost_per_batch: float + """Cost per batch of tokens""" + + token_count: float + """Total token count for this entry""" + + token_type: str + """Token category (e.g., "input", "output")""" + + @staticmethod + def from_dict(obj: Any) -> 'TokenDetail': + assert isinstance(obj, dict) + batch_size = from_float(obj.get("batchSize")) + cost_per_batch = from_float(obj.get("costPerBatch")) + token_count = from_float(obj.get("tokenCount")) + token_type = from_str(obj.get("tokenType")) + return TokenDetail(batch_size, cost_per_batch, token_count, token_type) + + def to_dict(self) -> dict: + result: dict = {} + result["batchSize"] = to_float(self.batch_size) + result["costPerBatch"] = to_float(self.cost_per_batch) + result["tokenCount"] = to_float(self.token_count) + result["tokenType"] = from_str(self.token_type) + return result + + +@dataclass +class CopilotUsage: + """Per-request cost and usage data from the CAPI copilot_usage response field""" + + token_details: list[TokenDetail] + """Itemized token usage breakdown""" + + total_nano_aiu: float + """Total cost in nano-AIU (AI Units) for this request""" + + @staticmethod + def from_dict(obj: Any) -> 'CopilotUsage': + assert isinstance(obj, dict) + token_details = from_list(TokenDetail.from_dict, obj.get("tokenDetails")) + total_nano_aiu = from_float(obj.get("totalNanoAiu")) + return CopilotUsage(token_details, total_nano_aiu) + + def to_dict(self) -> dict: + result: dict = {} + result["tokenDetails"] = from_list(lambda x: to_class(TokenDetail, x), self.token_details) + result["totalNanoAiu"] = to_float(self.total_nano_aiu) + return result + + @dataclass class ErrorClass: + """Error details when the tool execution failed + + Error details when the hook failed + """ message: str - code: Optional[str] = None - stack: Optional[str] = None + """Human-readable error message""" + + code: str | None = None + """Machine-readable error code""" + + stack: str | None = None + """Error stack trace, when available""" @staticmethod def from_dict(obj: Any) -> 'ErrorClass': @@ -304,8 +540,13 @@ def to_dict(self) -> dict: @dataclass class Metadata: - prompt_version: Optional[str] = None - variables: Optional[Dict[str, Any]] = None + """Metadata about the prompt template and its construction""" + + prompt_version: str | None = None + """Version identifier of the prompt template used""" + + variables: dict[str, Any] | None = None + """Template variables used when constructing the prompt""" @staticmethod def from_dict(obj: Any) -> 'Metadata': @@ -323,10 +564,19 @@ def to_dict(self) -> dict: return result +class Mode(Enum): + FORM = "form" + + @dataclass class Requests: + """Request count and cost metrics""" + cost: float + """Cumulative cost multiplier for requests to this model""" + count: float + """Total number of API requests made to this model""" @staticmethod def from_dict(obj: Any) -> 'Requests': @@ -344,10 +594,19 @@ def to_dict(self) -> dict: @dataclass class Usage: + """Token usage breakdown""" + cache_read_tokens: float + """Total tokens read from prompt cache across all requests""" + cache_write_tokens: float + """Total tokens written to prompt cache across all requests""" + input_tokens: float + """Total input tokens consumed across all requests to this model""" + output_tokens: float + """Total output tokens produced across all requests to this model""" @staticmethod def from_dict(obj: Any) -> 'Usage': @@ -370,7 +629,10 @@ def to_dict(self) -> dict: @dataclass class ModelMetric: requests: Requests + """Request count and cost metrics""" + usage: Usage + """Token usage breakdown""" @staticmethod def from_dict(obj: Any) -> 'ModelMetric': @@ -387,21 +649,256 @@ def to_dict(self) -> dict: class Operation(Enum): + """The type of operation performed on the plan file + + Whether the file was newly created or updated + """ CREATE = "create" DELETE = "delete" UPDATE = "update" +@dataclass +class Command: + identifier: str + """Command identifier (e.g., executable name)""" + + read_only: bool + """Whether this command is read-only (no side effects)""" + + @staticmethod + def from_dict(obj: Any) -> 'Command': + assert isinstance(obj, dict) + identifier = from_str(obj.get("identifier")) + read_only = from_bool(obj.get("readOnly")) + return Command(identifier, read_only) + + def to_dict(self) -> dict: + result: dict = {} + result["identifier"] = from_str(self.identifier) + result["readOnly"] = from_bool(self.read_only) + return result + + +class PermissionRequestKind(Enum): + CUSTOM_TOOL = "custom-tool" + MCP = "mcp" + MEMORY = "memory" + READ = "read" + SHELL = "shell" + URL = "url" + WRITE = "write" + + +@dataclass +class PossibleURL: + url: str + """URL that may be accessed by the command""" + + @staticmethod + def from_dict(obj: Any) -> 'PossibleURL': + assert isinstance(obj, dict) + url = from_str(obj.get("url")) + return PossibleURL(url) + + def to_dict(self) -> dict: + result: dict = {} + result["url"] = from_str(self.url) + return result + + +@dataclass +class PermissionRequest: + """Details of the permission being requested""" + + kind: PermissionRequestKind + """Permission kind discriminator""" + + can_offer_session_approval: bool | None = None + """Whether the UI can offer session-wide approval for this command pattern""" + + commands: list[Command] | None = None + """Parsed command identifiers found in the command text""" + + full_command_text: str | None = None + """The complete shell command text to be executed""" + + has_write_file_redirection: bool | None = None + """Whether the command includes a file write redirection (e.g., > or >>)""" + + intention: str | None = None + """Human-readable description of what the command intends to do + + Human-readable description of the intended file change + + Human-readable description of why the file is being read + + Human-readable description of why the URL is being accessed + """ + possible_paths: list[str] | None = None + """File paths that may be read or written by the command""" + + possible_urls: list[PossibleURL] | None = None + """URLs that may be accessed by the command""" + + tool_call_id: str | None = None + """Tool call ID that triggered this permission request""" + + warning: str | None = None + """Optional warning message about risks of running this command""" + + diff: str | None = None + """Unified diff showing the proposed changes""" + + file_name: str | None = None + """Path of the file being written to""" + + new_file_contents: str | None = None + """Complete new file contents for newly created files""" + + path: str | None = None + """Path of the file or directory being read""" + + args: Any = None + """Arguments to pass to the MCP tool + + Arguments to pass to the custom tool + """ + read_only: bool | None = None + """Whether this MCP tool is read-only (no side effects)""" + + server_name: str | None = None + """Name of the MCP server providing the tool""" + + tool_name: str | None = None + """Internal name of the MCP tool + + Name of the custom tool + """ + tool_title: str | None = None + """Human-readable title of the MCP tool""" + + url: str | None = None + """URL to be fetched""" + + citations: str | None = None + """Source references for the stored fact""" + + fact: str | None = None + """The fact or convention being stored""" + + subject: str | None = None + """Topic or subject of the memory being stored""" + + tool_description: str | None = None + """Description of what the custom tool does""" + + @staticmethod + def from_dict(obj: Any) -> 'PermissionRequest': + assert isinstance(obj, dict) + kind = PermissionRequestKind(obj.get("kind")) + can_offer_session_approval = from_union([from_bool, from_none], obj.get("canOfferSessionApproval")) + commands = from_union([lambda x: from_list(Command.from_dict, x), from_none], obj.get("commands")) + full_command_text = from_union([from_str, from_none], obj.get("fullCommandText")) + has_write_file_redirection = from_union([from_bool, from_none], obj.get("hasWriteFileRedirection")) + intention = from_union([from_str, from_none], obj.get("intention")) + possible_paths = from_union([lambda x: from_list(from_str, x), from_none], obj.get("possiblePaths")) + possible_urls = from_union([lambda x: from_list(PossibleURL.from_dict, x), from_none], obj.get("possibleUrls")) + tool_call_id = from_union([from_str, from_none], obj.get("toolCallId")) + warning = from_union([from_str, from_none], obj.get("warning")) + diff = from_union([from_str, from_none], obj.get("diff")) + file_name = from_union([from_str, from_none], obj.get("fileName")) + new_file_contents = from_union([from_str, from_none], obj.get("newFileContents")) + path = from_union([from_str, from_none], obj.get("path")) + args = obj.get("args") + read_only = from_union([from_bool, from_none], obj.get("readOnly")) + server_name = from_union([from_str, from_none], obj.get("serverName")) + tool_name = from_union([from_str, from_none], obj.get("toolName")) + tool_title = from_union([from_str, from_none], obj.get("toolTitle")) + url = from_union([from_str, from_none], obj.get("url")) + citations = from_union([from_str, from_none], obj.get("citations")) + fact = from_union([from_str, from_none], obj.get("fact")) + subject = from_union([from_str, from_none], obj.get("subject")) + tool_description = from_union([from_str, from_none], obj.get("toolDescription")) + return PermissionRequest(kind, can_offer_session_approval, commands, full_command_text, has_write_file_redirection, intention, possible_paths, possible_urls, tool_call_id, warning, diff, file_name, new_file_contents, path, args, read_only, server_name, tool_name, tool_title, url, citations, fact, subject, tool_description) + + def to_dict(self) -> dict: + result: dict = {} + result["kind"] = to_enum(PermissionRequestKind, self.kind) + if self.can_offer_session_approval is not None: + result["canOfferSessionApproval"] = from_union([from_bool, from_none], self.can_offer_session_approval) + if self.commands is not None: + result["commands"] = from_union([lambda x: from_list(lambda x: to_class(Command, x), x), from_none], self.commands) + if self.full_command_text is not None: + result["fullCommandText"] = from_union([from_str, from_none], self.full_command_text) + if self.has_write_file_redirection is not None: + result["hasWriteFileRedirection"] = from_union([from_bool, from_none], self.has_write_file_redirection) + if self.intention is not None: + result["intention"] = from_union([from_str, from_none], self.intention) + if self.possible_paths is not None: + result["possiblePaths"] = from_union([lambda x: from_list(from_str, x), from_none], self.possible_paths) + if self.possible_urls is not None: + result["possibleUrls"] = from_union([lambda x: from_list(lambda x: to_class(PossibleURL, x), x), from_none], self.possible_urls) + if self.tool_call_id is not None: + result["toolCallId"] = from_union([from_str, from_none], self.tool_call_id) + if self.warning is not None: + result["warning"] = from_union([from_str, from_none], self.warning) + if self.diff is not None: + result["diff"] = from_union([from_str, from_none], self.diff) + if self.file_name is not None: + result["fileName"] = from_union([from_str, from_none], self.file_name) + if self.new_file_contents is not None: + result["newFileContents"] = from_union([from_str, from_none], self.new_file_contents) + if self.path is not None: + result["path"] = from_union([from_str, from_none], self.path) + if self.args is not None: + result["args"] = self.args + if self.read_only is not None: + result["readOnly"] = from_union([from_bool, from_none], self.read_only) + if self.server_name is not None: + result["serverName"] = from_union([from_str, from_none], self.server_name) + if self.tool_name is not None: + result["toolName"] = from_union([from_str, from_none], self.tool_name) + if self.tool_title is not None: + result["toolTitle"] = from_union([from_str, from_none], self.tool_title) + if self.url is not None: + result["url"] = from_union([from_str, from_none], self.url) + if self.citations is not None: + result["citations"] = from_union([from_str, from_none], self.citations) + if self.fact is not None: + result["fact"] = from_union([from_str, from_none], self.fact) + if self.subject is not None: + result["subject"] = from_union([from_str, from_none], self.subject) + if self.tool_description is not None: + result["toolDescription"] = from_union([from_str, from_none], self.tool_description) + return result + + @dataclass class QuotaSnapshot: entitlement_requests: float + """Total requests allowed by the entitlement""" + is_unlimited_entitlement: bool + """Whether the user has an unlimited usage entitlement""" + overage: float + """Number of requests over the entitlement limit""" + overage_allowed_with_exhausted_quota: bool + """Whether overage is allowed when quota is exhausted""" + remaining_percentage: float + """Percentage of quota remaining (0.0 to 1.0)""" + usage_allowed_with_exhausted_quota: bool + """Whether usage is still permitted after quota exhaustion""" + used_requests: float - reset_date: Optional[datetime] = None + """Number of requests already consumed""" + + reset_date: datetime | None = None + """Date when the quota resets""" @staticmethod def from_dict(obj: Any) -> 'QuotaSnapshot': @@ -432,9 +929,16 @@ def to_dict(self) -> dict: @dataclass class RepositoryClass: + """Repository context for the handed-off session""" + name: str + """Repository name""" + owner: str - branch: Optional[str] = None + """Repository owner (user or organization)""" + + branch: str | None = None + """Git branch name, if applicable""" @staticmethod def from_dict(obj: Any) -> 'RepositoryClass': @@ -453,7 +957,41 @@ def to_dict(self) -> dict: return result +class RequestedSchemaType(Enum): + OBJECT = "object" + + +@dataclass +class RequestedSchema: + """JSON Schema describing the form fields to present to the user""" + + properties: dict[str, Any] + """Form field definitions, keyed by field name""" + + type: RequestedSchemaType + required: list[str] | None = None + """List of required field names""" + + @staticmethod + def from_dict(obj: Any) -> 'RequestedSchema': + assert isinstance(obj, dict) + properties = from_dict(lambda x: x, obj.get("properties")) + type = RequestedSchemaType(obj.get("type")) + required = from_union([lambda x: from_list(from_str, x), from_none], obj.get("required")) + return RequestedSchema(properties, type, required) + + def to_dict(self) -> dict: + result: dict = {} + result["properties"] = from_dict(lambda x: x, self.properties) + result["type"] = to_enum(RequestedSchemaType, self.type) + if self.required is not None: + result["required"] = from_union([lambda x: from_list(from_str, x), from_none], self.required) + return result + + class Theme(Enum): + """Theme variant this icon is intended for""" + DARK = "dark" LIGHT = "light" @@ -461,9 +999,16 @@ class Theme(Enum): @dataclass class Icon: src: str - mime_type: Optional[str] = None - sizes: Optional[List[str]] = None - theme: Optional[Theme] = None + """URL or path to the icon image""" + + mime_type: str | None = None + """MIME type of the icon image""" + + sizes: list[str] | None = None + """Available icon sizes (e.g., ['16x16', '32x32'])""" + + theme: Theme | None = None + """Theme variant this icon is intended for""" @staticmethod def from_dict(obj: Any) -> 'Icon': @@ -488,10 +1033,21 @@ def to_dict(self) -> dict: @dataclass class Resource: + """The embedded resource contents, either text or base64-encoded binary""" + uri: str - mime_type: Optional[str] = None - text: Optional[str] = None - blob: Optional[str] = None + """URI identifying the resource""" + + mime_type: str | None = None + """MIME type of the text content + + MIME type of the blob content + """ + text: str | None = None + """Text content of the resource""" + + blob: str | None = None + """Base64-encoded binary content of the resource""" @staticmethod def from_dict(obj: Any) -> 'Resource': @@ -526,18 +1082,51 @@ class ContentType(Enum): @dataclass class Content: type: ContentType - text: Optional[str] = None - cwd: Optional[str] = None - exit_code: Optional[float] = None - data: Optional[str] = None - mime_type: Optional[str] = None - description: Optional[str] = None - icons: Optional[List[Icon]] = None - name: Optional[str] = None - size: Optional[float] = None - title: Optional[str] = None - uri: Optional[str] = None - resource: Optional[Resource] = None + """Content block type discriminator""" + + text: str | None = None + """The text content + + Terminal/shell output text + """ + cwd: str | None = None + """Working directory where the command was executed""" + + exit_code: float | None = None + """Process exit code, if the command has completed""" + + data: str | None = None + """Base64-encoded image data + + Base64-encoded audio data + """ + mime_type: str | None = None + """MIME type of the image (e.g., image/png, image/jpeg) + + MIME type of the audio (e.g., audio/wav, audio/mpeg) + + MIME type of the resource content + """ + description: str | None = None + """Human-readable description of the resource""" + + icons: list[Icon] | None = None + """Icons associated with this resource""" + + name: str | None = None + """Resource name identifier""" + + size: float | None = None + """Size of the resource in bytes""" + + title: str | None = None + """Human-readable display title for the resource""" + + uri: str | None = None + """URI identifying the resource""" + + resource: Resource | None = None + """The embedded resource contents, either text or base64-encoded binary""" @staticmethod def from_dict(obj: Any) -> 'Content': @@ -587,46 +1176,84 @@ def to_dict(self) -> dict: return result +class ResultKind(Enum): + """The outcome of the permission request""" + + APPROVED = "approved" + DENIED_BY_CONTENT_EXCLUSION_POLICY = "denied-by-content-exclusion-policy" + DENIED_BY_RULES = "denied-by-rules" + DENIED_INTERACTIVELY_BY_USER = "denied-interactively-by-user" + DENIED_NO_APPROVAL_RULE_AND_COULD_NOT_REQUEST_FROM_USER = "denied-no-approval-rule-and-could-not-request-from-user" + + @dataclass class Result: - content: str - contents: Optional[List[Content]] = None - detailed_content: Optional[str] = None + """Tool execution result on success + + The result of the permission request + """ + content: str | None = None + """Concise tool result text sent to the LLM for chat completion, potentially truncated for + token efficiency + """ + contents: list[Content] | None = None + """Structured content blocks (text, images, audio, resources) returned by the tool in their + native format + """ + detailed_content: str | None = None + """Full detailed tool result for UI/timeline display, preserving complete content such as + diffs. Falls back to content when absent. + """ + kind: ResultKind | None = None + """The outcome of the permission request""" @staticmethod def from_dict(obj: Any) -> 'Result': assert isinstance(obj, dict) - content = from_str(obj.get("content")) + content = from_union([from_str, from_none], obj.get("content")) contents = from_union([lambda x: from_list(Content.from_dict, x), from_none], obj.get("contents")) detailed_content = from_union([from_str, from_none], obj.get("detailedContent")) - return Result(content, contents, detailed_content) + kind = from_union([ResultKind, from_none], obj.get("kind")) + return Result(content, contents, detailed_content, kind) def to_dict(self) -> dict: result: dict = {} - result["content"] = from_str(self.content) + if self.content is not None: + result["content"] = from_union([from_str, from_none], self.content) if self.contents is not None: result["contents"] = from_union([lambda x: from_list(lambda x: to_class(Content, x), x), from_none], self.contents) if self.detailed_content is not None: result["detailedContent"] = from_union([from_str, from_none], self.detailed_content) + if self.kind is not None: + result["kind"] = from_union([lambda x: to_enum(ResultKind, x), from_none], self.kind) return result class Role(Enum): + """Message role: "system" for system prompts, "developer" for developer-injected instructions""" + DEVELOPER = "developer" SYSTEM = "system" class ShutdownType(Enum): + """Whether the session ended normally ("routine") or due to a crash/fatal error ("error")""" + ERROR = "error" ROUTINE = "routine" class SourceType(Enum): + """Origin type of the session being handed off""" + LOCAL = "local" REMOTE = "remote" class ToolRequestType(Enum): + """Tool call type: "function" for standard tool calls, "custom" for grammar-based tool + calls. Defaults to "function" when absent. + """ CUSTOM = "custom" FUNCTION = "function" @@ -634,9 +1261,18 @@ class ToolRequestType(Enum): @dataclass class ToolRequest: name: str + """Name of the tool being invoked""" + tool_call_id: str + """Unique identifier for this tool call""" + arguments: Any = None - type: Optional[ToolRequestType] = None + """Arguments to pass to the tool, format depends on the tool""" + + type: ToolRequestType | None = None + """Tool call type: "function" for standard tool calls, "custom" for grammar-based tool + calls. Defaults to "function" when absent. + """ @staticmethod def from_dict(obj: Any) -> 'ToolRequest': @@ -660,121 +1296,532 @@ def to_dict(self) -> dict: @dataclass class Data: - context: Optional[Union[ContextClass, str]] = None - copilot_version: Optional[str] = None - producer: Optional[str] = None - selected_model: Optional[str] = None - session_id: Optional[str] = None - start_time: Optional[datetime] = None - version: Optional[float] = None - event_count: Optional[float] = None - resume_time: Optional[datetime] = None - error_type: Optional[str] = None - message: Optional[str] = None - provider_call_id: Optional[str] = None - stack: Optional[str] = None - status_code: Optional[int] = None - title: Optional[str] = None - info_type: Optional[str] = None - warning_type: Optional[str] = None - new_model: Optional[str] = None - previous_model: Optional[str] = None - new_mode: Optional[str] = None - previous_mode: Optional[str] = None - operation: Optional[Operation] = None - path: Optional[str] = None - """Relative path within the workspace files directory""" - - handoff_time: Optional[datetime] = None - remote_session_id: Optional[str] = None - repository: Optional[Union[RepositoryClass, str]] = None - source_type: Optional[SourceType] = None - summary: Optional[str] = None - messages_removed_during_truncation: Optional[float] = None - performed_by: Optional[str] = None - post_truncation_messages_length: Optional[float] = None - post_truncation_tokens_in_messages: Optional[float] = None - pre_truncation_messages_length: Optional[float] = None - pre_truncation_tokens_in_messages: Optional[float] = None - token_limit: Optional[float] = None - tokens_removed_during_truncation: Optional[float] = None - events_removed: Optional[float] = None - up_to_event_id: Optional[str] = None - code_changes: Optional[CodeChanges] = None - current_model: Optional[str] = None - error_reason: Optional[str] = None - model_metrics: Optional[Dict[str, ModelMetric]] = None - session_start_time: Optional[float] = None - shutdown_type: Optional[ShutdownType] = None - total_api_duration_ms: Optional[float] = None - total_premium_requests: Optional[float] = None - branch: Optional[str] = None - cwd: Optional[str] = None - git_root: Optional[str] = None - current_tokens: Optional[float] = None - messages_length: Optional[float] = None - checkpoint_number: Optional[float] = None - checkpoint_path: Optional[str] = None - compaction_tokens_used: Optional[CompactionTokensUsed] = None - error: Optional[Union[ErrorClass, str]] = None - messages_removed: Optional[float] = None - post_compaction_tokens: Optional[float] = None - pre_compaction_messages_length: Optional[float] = None - pre_compaction_tokens: Optional[float] = None - request_id: Optional[str] = None - success: Optional[bool] = None - summary_content: Optional[str] = None - tokens_removed: Optional[float] = None - agent_mode: Optional[AgentMode] = None - attachments: Optional[List[Attachment]] = None - content: Optional[str] = None - source: Optional[str] = None - transformed_content: Optional[str] = None - turn_id: Optional[str] = None - intent: Optional[str] = None - reasoning_id: Optional[str] = None - delta_content: Optional[str] = None - encrypted_content: Optional[str] = None - message_id: Optional[str] = None - parent_tool_call_id: Optional[str] = None - phase: Optional[str] = None - reasoning_opaque: Optional[str] = None - reasoning_text: Optional[str] = None - tool_requests: Optional[List[ToolRequest]] = None - total_response_size_bytes: Optional[float] = None - api_call_id: Optional[str] = None - cache_read_tokens: Optional[float] = None - cache_write_tokens: Optional[float] = None - cost: Optional[float] = None - duration: Optional[float] = None - initiator: Optional[str] = None - input_tokens: Optional[float] = None - model: Optional[str] = None - output_tokens: Optional[float] = None - quota_snapshots: Optional[Dict[str, QuotaSnapshot]] = None - reason: Optional[str] = None + """Payload indicating the agent is idle; includes any background tasks still in flight + + Empty payload; the event signals that LLM-powered conversation compaction has begun + + Empty payload; the event signals that the pending message queue has changed + + Empty payload; the event signals that the custom agent was deselected, returning to the + default agent + """ + context: ContextClass | str | None = None + """Working directory and git context at session start + + Updated working directory and git context at resume time + + Additional context information for the handoff + """ + copilot_version: str | None = None + """Version string of the Copilot application""" + + producer: str | None = None + """Identifier of the software producing the events (e.g., "copilot-agent")""" + + selected_model: str | None = None + """Model selected at session creation time, if any""" + + session_id: str | None = None + """Unique identifier for the session + + Session ID that this external tool request belongs to + """ + start_time: datetime | None = None + """ISO 8601 timestamp when the session was created""" + + version: float | None = None + """Schema version number for the session event format""" + + event_count: float | None = None + """Total number of persisted events in the session at the time of resume""" + + resume_time: datetime | None = None + """ISO 8601 timestamp when the session was resumed""" + + error_type: str | None = None + """Category of error (e.g., "authentication", "authorization", "quota", "rate_limit", + "query") + """ + message: str | None = None + """Human-readable error message + + Human-readable informational message for display in the timeline + + Human-readable warning message for display in the timeline + + Message describing what information is needed from the user + """ + provider_call_id: str | None = None + """GitHub request tracing ID (x-github-request-id header) for correlating with server-side + logs + + GitHub request tracing ID (x-github-request-id header) for server-side log correlation + """ + stack: str | None = None + """Error stack trace, when available""" + + status_code: int | None = None + """HTTP status code from the upstream request, if applicable""" + + background_tasks: BackgroundTasks | None = None + """Background tasks still running when the agent became idle""" + + title: str | None = None + """The new display title for the session""" + + info_type: str | None = None + """Category of informational message (e.g., "notification", "timing", "context_window", + "mcp", "snapshot", "configuration", "authentication", "model") + """ + warning_type: str | None = None + """Category of warning (e.g., "subscription", "policy", "mcp")""" + + new_model: str | None = None + """Newly selected model identifier""" + + previous_model: str | None = None + """Model that was previously selected, if any""" + + new_mode: str | None = None + """Agent mode after the change (e.g., "interactive", "plan", "autopilot")""" + + previous_mode: str | None = None + """Agent mode before the change (e.g., "interactive", "plan", "autopilot")""" + + operation: Operation | None = None + """The type of operation performed on the plan file + + Whether the file was newly created or updated + """ + path: str | None = None + """Relative path within the session workspace files directory + + File path to the SKILL.md definition + """ + handoff_time: datetime | None = None + """ISO 8601 timestamp when the handoff occurred""" + + remote_session_id: str | None = None + """Session ID of the remote session being handed off""" + + repository: RepositoryClass | str | None = None + """Repository context for the handed-off session + + Repository identifier in "owner/name" format, derived from the git remote URL + """ + source_type: SourceType | None = None + """Origin type of the session being handed off""" + + summary: str | None = None + """Summary of the work done in the source session + + Optional summary of the completed task, provided by the agent + + Summary of the plan that was created + """ + messages_removed_during_truncation: float | None = None + """Number of messages removed by truncation""" + + performed_by: str | None = None + """Identifier of the component that performed truncation (e.g., "BasicTruncator")""" + + post_truncation_messages_length: float | None = None + """Number of conversation messages after truncation""" + + post_truncation_tokens_in_messages: float | None = None + """Total tokens in conversation messages after truncation""" + + pre_truncation_messages_length: float | None = None + """Number of conversation messages before truncation""" + + pre_truncation_tokens_in_messages: float | None = None + """Total tokens in conversation messages before truncation""" + + token_limit: float | None = None + """Maximum token count for the model's context window""" + + tokens_removed_during_truncation: float | None = None + """Number of tokens removed by truncation""" + + events_removed: float | None = None + """Number of events that were removed by the rewind""" + + up_to_event_id: str | None = None + """Event ID that was rewound to; all events after this one were removed""" + + code_changes: CodeChanges | None = None + """Aggregate code change metrics for the session""" + + current_model: str | None = None + """Model that was selected at the time of shutdown""" + + error_reason: str | None = None + """Error description when shutdownType is "error\"""" + + model_metrics: dict[str, ModelMetric] | None = None + """Per-model usage breakdown, keyed by model identifier""" + + session_start_time: float | None = None + """Unix timestamp (milliseconds) when the session started""" + + shutdown_type: ShutdownType | None = None + """Whether the session ended normally ("routine") or due to a crash/fatal error ("error")""" + + total_api_duration_ms: float | None = None + """Cumulative time spent in API calls during the session, in milliseconds""" + + total_premium_requests: float | None = None + """Total number of premium API requests used during the session""" + + branch: str | None = None + """Current git branch name""" + + cwd: str | None = None + """Current working directory path""" + + git_root: str | None = None + """Root directory of the git repository, resolved via git rev-parse""" + + current_tokens: float | None = None + """Current number of tokens in the context window""" + + messages_length: float | None = None + """Current number of messages in the conversation""" + + checkpoint_number: float | None = None + """Checkpoint snapshot number created for recovery""" + + checkpoint_path: str | None = None + """File path where the checkpoint was stored""" + + compaction_tokens_used: CompactionTokensUsed | None = None + """Token usage breakdown for the compaction LLM call""" + + error: ErrorClass | str | None = None + """Error message if compaction failed + + Error details when the tool execution failed + + Error message describing why the sub-agent failed + + Error details when the hook failed + """ + messages_removed: float | None = None + """Number of messages removed during compaction""" + + post_compaction_tokens: float | None = None + """Total tokens in conversation after compaction""" + + pre_compaction_messages_length: float | None = None + """Number of messages before compaction""" + + pre_compaction_tokens: float | None = None + """Total tokens in conversation before compaction""" + + request_id: str | None = None + """GitHub request tracing ID (x-github-request-id header) for the compaction LLM call + + Unique identifier for this permission request; used to respond via + session.respondToPermission() + + Request ID of the resolved permission request; clients should dismiss any UI for this + request + + Unique identifier for this input request; used to respond via + session.respondToUserInput() + + Request ID of the resolved user input request; clients should dismiss any UI for this + request + + Unique identifier for this elicitation request; used to respond via + session.respondToElicitation() + + Request ID of the resolved elicitation request; clients should dismiss any UI for this + request + + Unique identifier for this request; used to respond via session.respondToExternalTool() + + Request ID of the resolved external tool request; clients should dismiss any UI for this + request + + Unique identifier for this request; used to respond via session.respondToQueuedCommand() + + Request ID of the resolved command request; clients should dismiss any UI for this + request + + Unique identifier for this request; used to respond via session.respondToExitPlanMode() + + Request ID of the resolved exit plan mode request; clients should dismiss any UI for this + request + """ + success: bool | None = None + """Whether compaction completed successfully + + Whether the tool execution completed successfully + + Whether the hook completed successfully + """ + summary_content: str | None = None + """LLM-generated summary of the compacted conversation history""" + + tokens_removed: float | None = None + """Number of tokens removed during compaction""" + + agent_mode: AgentMode | None = None + """The agent mode that was active when this message was sent""" + + attachments: list[Attachment] | None = None + """Files, selections, or GitHub references attached to the message""" + + content: str | None = None + """The user's message text as displayed in the timeline + + The complete extended thinking text from the model + + The assistant's text response content + + Full content of the skill file, injected into the conversation for the model + + The system or developer prompt text + """ + interaction_id: str | None = None + """CAPI interaction ID for correlating this user message with its turn + + CAPI interaction ID for correlating this turn with upstream telemetry + + CAPI interaction ID for correlating this message with upstream telemetry + + CAPI interaction ID for correlating this tool execution with upstream telemetry + """ + source: str | None = None + """Origin of this message, used for timeline filtering (e.g., "skill-pdf" for skill-injected + messages that should be hidden from the user) + """ + transformed_content: str | None = None + """Transformed version of the message sent to the model, with XML wrapping, timestamps, and + other augmentations for prompt caching + """ + turn_id: str | None = None + """Identifier for this turn within the agentic loop, typically a stringified turn number + + Identifier of the turn that has ended, matching the corresponding assistant.turn_start + event + """ + intent: str | None = None + """Short description of what the agent is currently doing or planning to do""" + + reasoning_id: str | None = None + """Unique identifier for this reasoning block + + Reasoning block ID this delta belongs to, matching the corresponding assistant.reasoning + event + """ + delta_content: str | None = None + """Incremental text chunk to append to the reasoning content + + Incremental text chunk to append to the message content + """ + total_response_size_bytes: float | None = None + """Cumulative total bytes received from the streaming response so far""" + + encrypted_content: str | None = None + """Encrypted reasoning content from OpenAI models. Session-bound and stripped on resume.""" + + message_id: str | None = None + """Unique identifier for this assistant message + + Message ID this delta belongs to, matching the corresponding assistant.message event + """ + output_tokens: float | None = None + """Actual output token count from the API response (completion_tokens), used for accurate + token accounting + + Number of output tokens produced + """ + parent_tool_call_id: str | None = None + """Tool call ID of the parent tool invocation when this event originates from a sub-agent + + Parent tool call ID when this usage originates from a sub-agent + """ + phase: str | None = None + """Generation phase for phased-output models (e.g., thinking vs. response phases)""" + + reasoning_opaque: str | None = None + """Opaque/encrypted extended thinking data from Anthropic models. Session-bound and stripped + on resume. + """ + reasoning_text: str | None = None + """Readable reasoning text from the model's extended thinking""" + + tool_requests: list[ToolRequest] | None = None + """Tool invocations requested by the assistant in this message""" + + api_call_id: str | None = None + """Completion ID from the model provider (e.g., chatcmpl-abc123)""" + + cache_read_tokens: float | None = None + """Number of tokens read from prompt cache""" + + cache_write_tokens: float | None = None + """Number of tokens written to prompt cache""" + + copilot_usage: CopilotUsage | None = None + """Per-request cost and usage data from the CAPI copilot_usage response field""" + + cost: float | None = None + """Model multiplier cost for billing purposes""" + + duration: float | None = None + """Duration of the API call in milliseconds""" + + initiator: str | None = None + """What initiated this API call (e.g., "sub-agent"); absent for user-initiated calls""" + + input_tokens: float | None = None + """Number of input tokens consumed""" + + model: str | None = None + """Model identifier used for this API call + + Model identifier that generated this tool call + """ + quota_snapshots: dict[str, QuotaSnapshot] | None = None + """Per-quota resource usage snapshots, keyed by quota identifier""" + + reason: str | None = None + """Reason the current turn was aborted (e.g., "user initiated")""" + arguments: Any = None - tool_call_id: Optional[str] = None - tool_name: Optional[str] = None - mcp_server_name: Optional[str] = None - mcp_tool_name: Optional[str] = None - partial_output: Optional[str] = None - progress_message: Optional[str] = None - is_user_requested: Optional[bool] = None - result: Optional[Result] = None - tool_telemetry: Optional[Dict[str, Any]] = None - allowed_tools: Optional[List[str]] = None - name: Optional[str] = None - agent_description: Optional[str] = None - agent_display_name: Optional[str] = None - agent_name: Optional[str] = None - tools: Optional[List[str]] = None - hook_invocation_id: Optional[str] = None - hook_type: Optional[str] = None + """Arguments for the tool invocation + + Arguments passed to the tool + + Arguments to pass to the external tool + """ + tool_call_id: str | None = None + """Unique identifier for this tool call + + Tool call ID this partial result belongs to + + Tool call ID this progress notification belongs to + + Unique identifier for the completed tool call + + Tool call ID of the parent tool invocation that spawned this sub-agent + + Tool call ID assigned to this external tool invocation + """ + tool_name: str | None = None + """Name of the tool the user wants to invoke + + Name of the tool being executed + + Name of the external tool to invoke + """ + mcp_server_name: str | None = None + """Name of the MCP server hosting this tool, when the tool is an MCP tool""" + + mcp_tool_name: str | None = None + """Original tool name on the MCP server, when the tool is an MCP tool""" + + partial_output: str | None = None + """Incremental output chunk from the running tool""" + + progress_message: str | None = None + """Human-readable progress status message (e.g., from an MCP server)""" + + is_user_requested: bool | None = None + """Whether this tool call was explicitly requested by the user rather than the assistant""" + + result: Result | None = None + """Tool execution result on success + + The result of the permission request + """ + tool_telemetry: dict[str, Any] | None = None + """Tool-specific telemetry data (e.g., CodeQL check counts, grep match counts)""" + + allowed_tools: list[str] | None = None + """Tool names that should be auto-approved when this skill is active""" + + name: str | None = None + """Name of the invoked skill + + Optional name identifier for the message source + """ + plugin_name: str | None = None + """Name of the plugin this skill originated from, when applicable""" + + plugin_version: str | None = None + """Version of the plugin this skill originated from, when applicable""" + + agent_description: str | None = None + """Description of what the sub-agent does""" + + agent_display_name: str | None = None + """Human-readable display name of the sub-agent + + Human-readable display name of the selected custom agent + """ + agent_name: str | None = None + """Internal name of the sub-agent + + Internal name of the selected custom agent + """ + tools: list[str] | None = None + """List of tool names available to this agent, or null for all tools""" + + hook_invocation_id: str | None = None + """Unique identifier for this hook invocation + + Identifier matching the corresponding hook.start event + """ + hook_type: str | None = None + """Type of hook being invoked (e.g., "preToolUse", "postToolUse", "sessionStart") + + Type of hook that was invoked (e.g., "preToolUse", "postToolUse", "sessionStart") + """ input: Any = None + """Input data passed to the hook""" + output: Any = None - metadata: Optional[Metadata] = None - role: Optional[Role] = None + """Output data produced by the hook""" + + metadata: Metadata | None = None + """Metadata about the prompt template and its construction""" + + role: Role | None = None + """Message role: "system" for system prompts, "developer" for developer-injected instructions""" + + permission_request: PermissionRequest | None = None + """Details of the permission being requested""" + + allow_freeform: bool | None = None + """Whether the user can provide a free-form text response in addition to predefined choices""" + + choices: list[str] | None = None + """Predefined choices for the user to select from, if applicable""" + + question: str | None = None + """The question or prompt to present to the user""" + + mode: Mode | None = None + """Elicitation mode; currently only "form" is supported. Defaults to "form" when absent.""" + + requested_schema: RequestedSchema | None = None + """JSON Schema describing the form fields to present to the user""" + + command: str | None = None + """The slash command text to be executed (e.g., /help, /clear)""" + + actions: list[str] | None = None + """Available actions the user can take (e.g., approve, edit, reject)""" + + plan_content: str | None = None + """Full content of the plan file""" + + recommended_action: str | None = None + """The recommended action for the user to take""" @staticmethod def from_dict(obj: Any) -> 'Data': @@ -793,6 +1840,7 @@ def from_dict(obj: Any) -> 'Data': provider_call_id = from_union([from_str, from_none], obj.get("providerCallId")) stack = from_union([from_str, from_none], obj.get("stack")) status_code = from_union([from_int, from_none], obj.get("statusCode")) + background_tasks = from_union([BackgroundTasks.from_dict, from_none], obj.get("backgroundTasks")) title = from_union([from_str, from_none], obj.get("title")) info_type = from_union([from_str, from_none], obj.get("infoType")) warning_type = from_union([from_str, from_none], obj.get("warningType")) @@ -845,29 +1893,31 @@ def from_dict(obj: Any) -> 'Data': agent_mode = from_union([AgentMode, from_none], obj.get("agentMode")) attachments = from_union([lambda x: from_list(Attachment.from_dict, x), from_none], obj.get("attachments")) content = from_union([from_str, from_none], obj.get("content")) + interaction_id = from_union([from_str, from_none], obj.get("interactionId")) source = from_union([from_str, from_none], obj.get("source")) transformed_content = from_union([from_str, from_none], obj.get("transformedContent")) turn_id = from_union([from_str, from_none], obj.get("turnId")) intent = from_union([from_str, from_none], obj.get("intent")) reasoning_id = from_union([from_str, from_none], obj.get("reasoningId")) delta_content = from_union([from_str, from_none], obj.get("deltaContent")) + total_response_size_bytes = from_union([from_float, from_none], obj.get("totalResponseSizeBytes")) encrypted_content = from_union([from_str, from_none], obj.get("encryptedContent")) message_id = from_union([from_str, from_none], obj.get("messageId")) + output_tokens = from_union([from_float, from_none], obj.get("outputTokens")) parent_tool_call_id = from_union([from_str, from_none], obj.get("parentToolCallId")) phase = from_union([from_str, from_none], obj.get("phase")) reasoning_opaque = from_union([from_str, from_none], obj.get("reasoningOpaque")) reasoning_text = from_union([from_str, from_none], obj.get("reasoningText")) tool_requests = from_union([lambda x: from_list(ToolRequest.from_dict, x), from_none], obj.get("toolRequests")) - total_response_size_bytes = from_union([from_float, from_none], obj.get("totalResponseSizeBytes")) api_call_id = from_union([from_str, from_none], obj.get("apiCallId")) cache_read_tokens = from_union([from_float, from_none], obj.get("cacheReadTokens")) cache_write_tokens = from_union([from_float, from_none], obj.get("cacheWriteTokens")) + copilot_usage = from_union([CopilotUsage.from_dict, from_none], obj.get("copilotUsage")) cost = from_union([from_float, from_none], obj.get("cost")) duration = from_union([from_float, from_none], obj.get("duration")) initiator = from_union([from_str, from_none], obj.get("initiator")) input_tokens = from_union([from_float, from_none], obj.get("inputTokens")) model = from_union([from_str, from_none], obj.get("model")) - output_tokens = from_union([from_float, from_none], obj.get("outputTokens")) quota_snapshots = from_union([lambda x: from_dict(QuotaSnapshot.from_dict, x), from_none], obj.get("quotaSnapshots")) reason = from_union([from_str, from_none], obj.get("reason")) arguments = obj.get("arguments") @@ -882,6 +1932,8 @@ def from_dict(obj: Any) -> 'Data': tool_telemetry = from_union([lambda x: from_dict(lambda x: x, x), from_none], obj.get("toolTelemetry")) allowed_tools = from_union([lambda x: from_list(from_str, x), from_none], obj.get("allowedTools")) name = from_union([from_str, from_none], obj.get("name")) + plugin_name = from_union([from_str, from_none], obj.get("pluginName")) + plugin_version = from_union([from_str, from_none], obj.get("pluginVersion")) agent_description = from_union([from_str, from_none], obj.get("agentDescription")) agent_display_name = from_union([from_str, from_none], obj.get("agentDisplayName")) agent_name = from_union([from_str, from_none], obj.get("agentName")) @@ -892,7 +1944,17 @@ def from_dict(obj: Any) -> 'Data': output = obj.get("output") metadata = from_union([Metadata.from_dict, from_none], obj.get("metadata")) role = from_union([Role, from_none], obj.get("role")) - return Data(context, copilot_version, producer, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, title, info_type, warning_type, new_model, previous_model, new_mode, previous_mode, operation, path, handoff_time, remote_session_id, repository, source_type, summary, messages_removed_during_truncation, performed_by, post_truncation_messages_length, post_truncation_tokens_in_messages, pre_truncation_messages_length, pre_truncation_tokens_in_messages, token_limit, tokens_removed_during_truncation, events_removed, up_to_event_id, code_changes, current_model, error_reason, model_metrics, session_start_time, shutdown_type, total_api_duration_ms, total_premium_requests, branch, cwd, git_root, current_tokens, messages_length, checkpoint_number, checkpoint_path, compaction_tokens_used, error, messages_removed, post_compaction_tokens, pre_compaction_messages_length, pre_compaction_tokens, request_id, success, summary_content, tokens_removed, agent_mode, attachments, content, source, transformed_content, turn_id, intent, reasoning_id, delta_content, encrypted_content, message_id, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, total_response_size_bytes, api_call_id, cache_read_tokens, cache_write_tokens, cost, duration, initiator, input_tokens, model, output_tokens, quota_snapshots, reason, arguments, tool_call_id, tool_name, mcp_server_name, mcp_tool_name, partial_output, progress_message, is_user_requested, result, tool_telemetry, allowed_tools, name, agent_description, agent_display_name, agent_name, tools, hook_invocation_id, hook_type, input, output, metadata, role) + permission_request = from_union([PermissionRequest.from_dict, from_none], obj.get("permissionRequest")) + allow_freeform = from_union([from_bool, from_none], obj.get("allowFreeform")) + choices = from_union([lambda x: from_list(from_str, x), from_none], obj.get("choices")) + question = from_union([from_str, from_none], obj.get("question")) + mode = from_union([Mode, from_none], obj.get("mode")) + requested_schema = from_union([RequestedSchema.from_dict, from_none], obj.get("requestedSchema")) + command = from_union([from_str, from_none], obj.get("command")) + actions = from_union([lambda x: from_list(from_str, x), from_none], obj.get("actions")) + plan_content = from_union([from_str, from_none], obj.get("planContent")) + recommended_action = from_union([from_str, from_none], obj.get("recommendedAction")) + return Data(context, copilot_version, producer, selected_model, session_id, start_time, version, event_count, resume_time, error_type, message, provider_call_id, stack, status_code, background_tasks, title, info_type, warning_type, new_model, previous_model, new_mode, previous_mode, operation, path, handoff_time, remote_session_id, repository, source_type, summary, messages_removed_during_truncation, performed_by, post_truncation_messages_length, post_truncation_tokens_in_messages, pre_truncation_messages_length, pre_truncation_tokens_in_messages, token_limit, tokens_removed_during_truncation, events_removed, up_to_event_id, code_changes, current_model, error_reason, model_metrics, session_start_time, shutdown_type, total_api_duration_ms, total_premium_requests, branch, cwd, git_root, current_tokens, messages_length, checkpoint_number, checkpoint_path, compaction_tokens_used, error, messages_removed, post_compaction_tokens, pre_compaction_messages_length, pre_compaction_tokens, request_id, success, summary_content, tokens_removed, agent_mode, attachments, content, interaction_id, source, transformed_content, turn_id, intent, reasoning_id, delta_content, total_response_size_bytes, encrypted_content, message_id, output_tokens, parent_tool_call_id, phase, reasoning_opaque, reasoning_text, tool_requests, api_call_id, cache_read_tokens, cache_write_tokens, copilot_usage, cost, duration, initiator, input_tokens, model, quota_snapshots, reason, arguments, tool_call_id, tool_name, mcp_server_name, mcp_tool_name, partial_output, progress_message, is_user_requested, result, tool_telemetry, allowed_tools, name, plugin_name, plugin_version, agent_description, agent_display_name, agent_name, tools, hook_invocation_id, hook_type, input, output, metadata, role, permission_request, allow_freeform, choices, question, mode, requested_schema, command, actions, plan_content, recommended_action) def to_dict(self) -> dict: result: dict = {} @@ -924,6 +1986,8 @@ def to_dict(self) -> dict: result["stack"] = from_union([from_str, from_none], self.stack) if self.status_code is not None: result["statusCode"] = from_union([from_int, from_none], self.status_code) + if self.background_tasks is not None: + result["backgroundTasks"] = from_union([lambda x: to_class(BackgroundTasks, x), from_none], self.background_tasks) if self.title is not None: result["title"] = from_union([from_str, from_none], self.title) if self.info_type is not None: @@ -1028,6 +2092,8 @@ def to_dict(self) -> dict: result["attachments"] = from_union([lambda x: from_list(lambda x: to_class(Attachment, x), x), from_none], self.attachments) if self.content is not None: result["content"] = from_union([from_str, from_none], self.content) + if self.interaction_id is not None: + result["interactionId"] = from_union([from_str, from_none], self.interaction_id) if self.source is not None: result["source"] = from_union([from_str, from_none], self.source) if self.transformed_content is not None: @@ -1040,10 +2106,14 @@ def to_dict(self) -> dict: result["reasoningId"] = from_union([from_str, from_none], self.reasoning_id) if self.delta_content is not None: result["deltaContent"] = from_union([from_str, from_none], self.delta_content) + if self.total_response_size_bytes is not None: + result["totalResponseSizeBytes"] = from_union([to_float, from_none], self.total_response_size_bytes) if self.encrypted_content is not None: result["encryptedContent"] = from_union([from_str, from_none], self.encrypted_content) if self.message_id is not None: result["messageId"] = from_union([from_str, from_none], self.message_id) + if self.output_tokens is not None: + result["outputTokens"] = from_union([to_float, from_none], self.output_tokens) if self.parent_tool_call_id is not None: result["parentToolCallId"] = from_union([from_str, from_none], self.parent_tool_call_id) if self.phase is not None: @@ -1054,14 +2124,14 @@ def to_dict(self) -> dict: result["reasoningText"] = from_union([from_str, from_none], self.reasoning_text) if self.tool_requests is not None: result["toolRequests"] = from_union([lambda x: from_list(lambda x: to_class(ToolRequest, x), x), from_none], self.tool_requests) - if self.total_response_size_bytes is not None: - result["totalResponseSizeBytes"] = from_union([to_float, from_none], self.total_response_size_bytes) if self.api_call_id is not None: result["apiCallId"] = from_union([from_str, from_none], self.api_call_id) if self.cache_read_tokens is not None: result["cacheReadTokens"] = from_union([to_float, from_none], self.cache_read_tokens) if self.cache_write_tokens is not None: result["cacheWriteTokens"] = from_union([to_float, from_none], self.cache_write_tokens) + if self.copilot_usage is not None: + result["copilotUsage"] = from_union([lambda x: to_class(CopilotUsage, x), from_none], self.copilot_usage) if self.cost is not None: result["cost"] = from_union([to_float, from_none], self.cost) if self.duration is not None: @@ -1072,8 +2142,6 @@ def to_dict(self) -> dict: result["inputTokens"] = from_union([to_float, from_none], self.input_tokens) if self.model is not None: result["model"] = from_union([from_str, from_none], self.model) - if self.output_tokens is not None: - result["outputTokens"] = from_union([to_float, from_none], self.output_tokens) if self.quota_snapshots is not None: result["quotaSnapshots"] = from_union([lambda x: from_dict(lambda x: to_class(QuotaSnapshot, x), x), from_none], self.quota_snapshots) if self.reason is not None: @@ -1102,6 +2170,10 @@ def to_dict(self) -> dict: result["allowedTools"] = from_union([lambda x: from_list(from_str, x), from_none], self.allowed_tools) if self.name is not None: result["name"] = from_union([from_str, from_none], self.name) + if self.plugin_name is not None: + result["pluginName"] = from_union([from_str, from_none], self.plugin_name) + if self.plugin_version is not None: + result["pluginVersion"] = from_union([from_str, from_none], self.plugin_version) if self.agent_description is not None: result["agentDescription"] = from_union([from_str, from_none], self.agent_description) if self.agent_display_name is not None: @@ -1122,6 +2194,26 @@ def to_dict(self) -> dict: result["metadata"] = from_union([lambda x: to_class(Metadata, x), from_none], self.metadata) if self.role is not None: result["role"] = from_union([lambda x: to_enum(Role, x), from_none], self.role) + if self.permission_request is not None: + result["permissionRequest"] = from_union([lambda x: to_class(PermissionRequest, x), from_none], self.permission_request) + if self.allow_freeform is not None: + result["allowFreeform"] = from_union([from_bool, from_none], self.allow_freeform) + if self.choices is not None: + result["choices"] = from_union([lambda x: from_list(from_str, x), from_none], self.choices) + if self.question is not None: + result["question"] = from_union([from_str, from_none], self.question) + if self.mode is not None: + result["mode"] = from_union([lambda x: to_enum(Mode, x), from_none], self.mode) + if self.requested_schema is not None: + result["requestedSchema"] = from_union([lambda x: to_class(RequestedSchema, x), from_none], self.requested_schema) + if self.command is not None: + result["command"] = from_union([from_str, from_none], self.command) + if self.actions is not None: + result["actions"] = from_union([lambda x: from_list(from_str, x), from_none], self.actions) + if self.plan_content is not None: + result["planContent"] = from_union([from_str, from_none], self.plan_content) + if self.recommended_action is not None: + result["recommendedAction"] = from_union([from_str, from_none], self.recommended_action) return result @@ -1132,12 +2224,23 @@ class SessionEventType(Enum): ASSISTANT_MESSAGE_DELTA = "assistant.message_delta" ASSISTANT_REASONING = "assistant.reasoning" ASSISTANT_REASONING_DELTA = "assistant.reasoning_delta" + ASSISTANT_STREAMING_DELTA = "assistant.streaming_delta" ASSISTANT_TURN_END = "assistant.turn_end" ASSISTANT_TURN_START = "assistant.turn_start" ASSISTANT_USAGE = "assistant.usage" + COMMAND_COMPLETED = "command.completed" + COMMAND_QUEUED = "command.queued" + ELICITATION_COMPLETED = "elicitation.completed" + ELICITATION_REQUESTED = "elicitation.requested" + EXIT_PLAN_MODE_COMPLETED = "exit_plan_mode.completed" + EXIT_PLAN_MODE_REQUESTED = "exit_plan_mode.requested" + EXTERNAL_TOOL_COMPLETED = "external_tool.completed" + EXTERNAL_TOOL_REQUESTED = "external_tool.requested" HOOK_END = "hook.end" HOOK_START = "hook.start" PENDING_MESSAGES_MODIFIED = "pending_messages.modified" + PERMISSION_COMPLETED = "permission.completed" + PERMISSION_REQUESTED = "permission.requested" SESSION_COMPACTION_COMPLETE = "session.compaction_complete" SESSION_COMPACTION_START = "session.compaction_start" SESSION_CONTEXT_CHANGED = "session.context_changed" @@ -1152,6 +2255,7 @@ class SessionEventType(Enum): SESSION_SHUTDOWN = "session.shutdown" SESSION_SNAPSHOT_REWIND = "session.snapshot_rewind" SESSION_START = "session.start" + SESSION_TASK_COMPLETE = "session.task_complete" SESSION_TITLE_CHANGED = "session.title_changed" SESSION_TRUNCATION = "session.truncation" SESSION_USAGE_INFO = "session.usage_info" @@ -1159,6 +2263,7 @@ class SessionEventType(Enum): SESSION_WORKSPACE_FILE_CHANGED = "session.workspace_file_changed" SKILL_INVOKED = "skill.invoked" SUBAGENT_COMPLETED = "subagent.completed" + SUBAGENT_DESELECTED = "subagent.deselected" SUBAGENT_FAILED = "subagent.failed" SUBAGENT_SELECTED = "subagent.selected" SUBAGENT_STARTED = "subagent.started" @@ -1168,6 +2273,8 @@ class SessionEventType(Enum): TOOL_EXECUTION_PROGRESS = "tool.execution_progress" TOOL_EXECUTION_START = "tool.execution_start" TOOL_USER_REQUESTED = "tool.user_requested" + USER_INPUT_COMPLETED = "user_input.completed" + USER_INPUT_REQUESTED = "user_input.requested" USER_MESSAGE = "user.message" # UNKNOWN is used for forward compatibility UNKNOWN = "unknown" @@ -1182,11 +2289,29 @@ def _missing_(cls, value: object) -> "SessionEventType": @dataclass class SessionEvent: data: Data + """Payload indicating the agent is idle; includes any background tasks still in flight + + Empty payload; the event signals that LLM-powered conversation compaction has begun + + Empty payload; the event signals that the pending message queue has changed + + Empty payload; the event signals that the custom agent was deselected, returning to the + default agent + """ id: UUID + """Unique event identifier (UUID v4), generated when the event is emitted""" + timestamp: datetime + """ISO 8601 timestamp when the event was created""" + type: SessionEventType - ephemeral: Optional[bool] = None - parent_id: Optional[UUID] = None + ephemeral: bool | None = None + """When true, the event is transient and not persisted to the session event log on disk""" + + parent_id: UUID | None = None + """ID of the chronologically preceding event in the session, forming a linked chain. Null + for the first event. + """ @staticmethod def from_dict(obj: Any) -> 'SessionEvent': diff --git a/python/copilot/jsonrpc.py b/python/copilot/jsonrpc.py index cb6c5408d..fc8255274 100644 --- a/python/copilot/jsonrpc.py +++ b/python/copilot/jsonrpc.py @@ -10,8 +10,8 @@ import json import threading import uuid -from collections.abc import Awaitable -from typing import Any, Callable, Optional, Union +from collections.abc import Awaitable, Callable +from typing import Any class JsonRpcError(Exception): @@ -30,7 +30,7 @@ class ProcessExitedError(Exception): pass -RequestHandler = Callable[[dict], Union[dict, Awaitable[dict]]] +RequestHandler = Callable[[dict], dict | Awaitable[dict]] class JsonRpcClient: @@ -49,19 +49,19 @@ def __init__(self, process): """ self.process = process self.pending_requests: dict[str, asyncio.Future] = {} - self.notification_handler: Optional[Callable[[str, dict], None]] = None + self.notification_handler: Callable[[str, dict], None] | None = None self.request_handlers: dict[str, RequestHandler] = {} self._running = False - self._read_thread: Optional[threading.Thread] = None - self._stderr_thread: Optional[threading.Thread] = None - self._loop: Optional[asyncio.AbstractEventLoop] = None + self._read_thread: threading.Thread | None = None + self._stderr_thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None self._write_lock = threading.Lock() self._pending_lock = threading.Lock() - self._process_exit_error: Optional[str] = None + self._process_exit_error: str | None = None self._stderr_output: list[str] = [] self._stderr_lock = threading.Lock() - def start(self, loop: Optional[asyncio.AbstractEventLoop] = None): + def start(self, loop: asyncio.AbstractEventLoop | None = None): """Start listening for messages in background thread""" if not self._running: self._running = True @@ -104,7 +104,7 @@ async def stop(self): self._stderr_thread.join(timeout=1.0) async def request( - self, method: str, params: Optional[dict] = None, timeout: float = 30.0 + self, method: str, params: dict | None = None, timeout: float | None = None ) -> Any: """ Send a JSON-RPC request and wait for response @@ -112,14 +112,15 @@ async def request( Args: method: Method name params: Optional parameters - timeout: Request timeout in seconds (default 30s) + timeout: Optional request timeout in seconds. If None (default), + waits indefinitely for the server to respond. Returns: The result from the response Raises: JsonRpcError: If server returns an error - asyncio.TimeoutError: If request times out + asyncio.TimeoutError: If request times out (only when timeout is set) """ request_id = str(uuid.uuid4()) @@ -141,12 +142,14 @@ async def request( await self._send_message(message) try: - return await asyncio.wait_for(future, timeout=timeout) + if timeout is not None: + return await asyncio.wait_for(future, timeout=timeout) + return await future finally: with self._pending_lock: self.pending_requests.pop(request_id, None) - async def notify(self, method: str, params: Optional[dict] = None): + async def notify(self, method: str, params: dict | None = None): """ Send a JSON-RPC notification (no response expected) @@ -255,7 +258,7 @@ def _read_exact(self, num_bytes: int) -> bytes: remaining -= len(chunk) return b"".join(chunks) - def _read_message(self) -> Optional[dict]: + def _read_message(self) -> dict | None: """ Read a single JSON-RPC message with Content-Length header (blocking) @@ -364,7 +367,7 @@ async def _send_response(self, request_id: str, result: dict): await self._send_message(response) async def _send_error_response( - self, request_id: str, code: int, message: str, data: Optional[dict] + self, request_id: str, code: int, message: str, data: dict | None ): response = { "jsonrpc": "2.0", diff --git a/python/copilot/sdk_protocol_version.py b/python/copilot/sdk_protocol_version.py index 770082670..7af648d62 100644 --- a/python/copilot/sdk_protocol_version.py +++ b/python/copilot/sdk_protocol_version.py @@ -6,7 +6,7 @@ This must match the version expected by the copilot-agent-runtime server. """ -SDK_PROTOCOL_VERSION = 2 +SDK_PROTOCOL_VERSION = 3 def get_sdk_protocol_version() -> int: diff --git a/python/copilot/session.py b/python/copilot/session.py index 7332f6c5f..e0e72fc68 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -8,15 +8,29 @@ import asyncio import inspect import threading -from typing import Any, Callable, Optional - -from .generated.rpc import SessionRpc +from collections.abc import Callable +from typing import Any, cast + +from .generated.rpc import ( + Kind, + ResultResult, + SessionModelSwitchToParams, + SessionPermissionsHandlePendingPermissionRequestParams, + SessionPermissionsHandlePendingPermissionRequestParamsResult, + SessionRpc, + SessionToolsHandlePendingToolCallParams, +) from .generated.session_events import SessionEvent, SessionEventType, session_event_from_dict +from .jsonrpc import JsonRpcError, ProcessExitedError from .types import ( MessageOptions, + PermissionRequest, + PermissionRequestResult, SessionHooks, Tool, ToolHandler, + ToolInvocation, + ToolResult, UserInputHandler, UserInputRequest, UserInputResponse, @@ -53,7 +67,7 @@ class CopilotSession: ... unsubscribe() """ - def __init__(self, session_id: str, client: Any, workspace_path: Optional[str] = None): + def __init__(self, session_id: str, client: Any, workspace_path: str | None = None): """ Initialize a new CopilotSession. @@ -74,13 +88,13 @@ def __init__(self, session_id: str, client: Any, workspace_path: Optional[str] = self._event_handlers_lock = threading.Lock() self._tool_handlers: dict[str, ToolHandler] = {} self._tool_handlers_lock = threading.Lock() - self._permission_handler: Optional[_PermissionHandlerFn] = None + self._permission_handler: _PermissionHandlerFn | None = None self._permission_handler_lock = threading.Lock() - self._user_input_handler: Optional[UserInputHandler] = None + self._user_input_handler: UserInputHandler | None = None self._user_input_handler_lock = threading.Lock() - self._hooks: Optional[SessionHooks] = None + self._hooks: SessionHooks | None = None self._hooks_lock = threading.Lock() - self._rpc: Optional[SessionRpc] = None + self._rpc: SessionRpc | None = None @property def rpc(self) -> SessionRpc: @@ -90,7 +104,7 @@ def rpc(self) -> SessionRpc: return self._rpc @property - def workspace_path(self) -> Optional[str]: + def workspace_path(self) -> str | None: """ Path to the session workspace directory when infinite sessions are enabled. @@ -115,7 +129,7 @@ async def send(self, options: MessageOptions) -> str: The message ID of the response, which can be used to correlate events. Raises: - Exception: If the session has been destroyed or the connection fails. + Exception: If the session has been disconnected or the connection fails. Example: >>> message_id = await session.send({ @@ -135,8 +149,8 @@ async def send(self, options: MessageOptions) -> str: return response["messageId"] async def send_and_wait( - self, options: MessageOptions, timeout: Optional[float] = None - ) -> Optional[SessionEvent]: + self, options: MessageOptions, timeout: float | None = None + ) -> SessionEvent | None: """ Send a message to this session and wait until the session becomes idle. @@ -155,8 +169,8 @@ async def send_and_wait( The final assistant message event, or None if none was received. Raises: - asyncio.TimeoutError: If the timeout is reached before session becomes idle. - Exception: If the session has been destroyed or the connection fails. + TimeoutError: If the timeout is reached before session becomes idle. + Exception: If the session has been disconnected or the connection fails. Example: >>> response = await session.send_and_wait({"prompt": "What is 2+2?"}) @@ -166,8 +180,8 @@ async def send_and_wait( effective_timeout = timeout if timeout is not None else 60.0 idle_event = asyncio.Event() - error_event: Optional[Exception] = None - last_assistant_message: Optional[SessionEvent] = None + error_event: Exception | None = None + last_assistant_message: SessionEvent | None = None def handler(event: SessionEventTypeAlias) -> None: nonlocal last_assistant_message, error_event @@ -188,10 +202,8 @@ def handler(event: SessionEventTypeAlias) -> None: if error_event: raise error_event return last_assistant_message - except asyncio.TimeoutError: - raise asyncio.TimeoutError( - f"Timeout after {effective_timeout}s waiting for session.idle" - ) + except TimeoutError: + raise TimeoutError(f"Timeout after {effective_timeout}s waiting for session.idle") finally: unsubscribe() @@ -235,12 +247,19 @@ def _dispatch_event(self, event: SessionEvent) -> None: """ Dispatch an event to all registered handlers. + Broadcast request events (external_tool.requested, permission.requested) are handled + internally before being forwarded to user handlers. + Note: This method is internal and should not be called directly. Args: event: The session event to dispatch to all handlers. """ + # Handle broadcast request events (protocol v3) before dispatching to user handlers. + # Fire-and-forget: the response is sent asynchronously via RPC. + self._handle_broadcast_event(event) + with self._event_handlers_lock: handlers = list(self._event_handlers) @@ -250,7 +269,151 @@ def _dispatch_event(self, event: SessionEvent) -> None: except Exception as e: print(f"Error in session event handler: {e}") - def _register_tools(self, tools: Optional[list[Tool]]) -> None: + def _handle_broadcast_event(self, event: SessionEvent) -> None: + """Handle broadcast request events by executing local handlers and responding via RPC. + + Implements the protocol v3 broadcast model where tool calls and permission requests + are broadcast as session events to all clients. + """ + if event.type == SessionEventType.EXTERNAL_TOOL_REQUESTED: + request_id = event.data.request_id + tool_name = event.data.tool_name + if not request_id or not tool_name: + return + + handler = self._get_tool_handler(tool_name) + if not handler: + return # This client doesn't handle this tool; another client will. + + tool_call_id = event.data.tool_call_id or "" + arguments = event.data.arguments + asyncio.ensure_future( + self._execute_tool_and_respond( + request_id, tool_name, tool_call_id, arguments, handler + ) + ) + + elif event.type == SessionEventType.PERMISSION_REQUESTED: + request_id = event.data.request_id + permission_request = event.data.permission_request + if not request_id or not permission_request: + return + + with self._permission_handler_lock: + perm_handler = self._permission_handler + if not perm_handler: + return # This client doesn't handle permissions; another client will. + + asyncio.ensure_future( + self._execute_permission_and_respond(request_id, permission_request, perm_handler) + ) + + async def _execute_tool_and_respond( + self, + request_id: str, + tool_name: str, + tool_call_id: str, + arguments: Any, + handler: ToolHandler, + ) -> None: + """Execute a tool handler and send the result back via HandlePendingToolCall RPC.""" + try: + invocation = ToolInvocation( + session_id=self.session_id, + tool_call_id=tool_call_id, + tool_name=tool_name, + arguments=arguments, + ) + + result = handler(invocation) + if inspect.isawaitable(result): + result = await result + + tool_result: ToolResult + if result is None: + tool_result = ToolResult( + text_result_for_llm="Tool returned no result.", + result_type="failure", + error="tool returned no result", + tool_telemetry={}, + ) + else: + tool_result = result # type: ignore[assignment] + + # If the tool reported a failure with an error message, send it via the + # top-level error param so the server formats the tool message consistently + # with other SDKs (e.g., "Failed to execute 'tool' ... due to error: ..."). + if tool_result.result_type == "failure" and tool_result.error: + await self.rpc.tools.handle_pending_tool_call( + SessionToolsHandlePendingToolCallParams( + request_id=request_id, + error=tool_result.error, + ) + ) + else: + await self.rpc.tools.handle_pending_tool_call( + SessionToolsHandlePendingToolCallParams( + request_id=request_id, + result=ResultResult( + text_result_for_llm=tool_result.text_result_for_llm, + result_type=tool_result.result_type, + tool_telemetry=tool_result.tool_telemetry, + ), + ) + ) + except Exception as exc: + try: + await self.rpc.tools.handle_pending_tool_call( + SessionToolsHandlePendingToolCallParams( + request_id=request_id, + error=str(exc), + ) + ) + except (JsonRpcError, ProcessExitedError, OSError): + pass # Connection lost or RPC error — nothing we can do + + async def _execute_permission_and_respond( + self, + request_id: str, + permission_request: Any, + handler: _PermissionHandlerFn, + ) -> None: + """Execute a permission handler and respond via RPC.""" + try: + result = handler(permission_request, {"session_id": self.session_id}) + if inspect.isawaitable(result): + result = await result + + result = cast(PermissionRequestResult, result) + + perm_result = SessionPermissionsHandlePendingPermissionRequestParamsResult( + kind=Kind(result.kind), + rules=result.rules, + feedback=result.feedback, + message=result.message, + path=result.path, + ) + + await self.rpc.permissions.handle_pending_permission_request( + SessionPermissionsHandlePendingPermissionRequestParams( + request_id=request_id, + result=perm_result, + ) + ) + except Exception: + try: + await self.rpc.permissions.handle_pending_permission_request( + SessionPermissionsHandlePendingPermissionRequestParams( + request_id=request_id, + result=SessionPermissionsHandlePendingPermissionRequestParamsResult( + kind=Kind.DENIED_NO_APPROVAL_RULE_AND_COULD_NOT_REQUEST_FROM_USER, + ), + ) + ) + except (JsonRpcError, ProcessExitedError, OSError): + pass # Connection lost or RPC error — nothing we can do + + def _register_tools(self, tools: list[Tool] | None) -> None: """ Register custom tool handlers for this session. @@ -274,7 +437,7 @@ def _register_tools(self, tools: Optional[list[Tool]]) -> None: continue self._tool_handlers[tool.name] = tool.handler - def _get_tool_handler(self, name: str) -> Optional[ToolHandler]: + def _get_tool_handler(self, name: str) -> ToolHandler | None: """ Retrieve a registered tool handler by name. @@ -291,7 +454,7 @@ def _get_tool_handler(self, name: str) -> Optional[ToolHandler]: with self._tool_handlers_lock: return self._tool_handlers.get(name) - def _register_permission_handler(self, handler: Optional[_PermissionHandlerFn]) -> None: + def _register_permission_handler(self, handler: _PermissionHandlerFn | None) -> None: """ Register a handler for permission requests. @@ -308,7 +471,9 @@ def _register_permission_handler(self, handler: Optional[_PermissionHandlerFn]) with self._permission_handler_lock: self._permission_handler = handler - async def _handle_permission_request(self, request: dict) -> dict: + async def _handle_permission_request( + self, request: PermissionRequest + ) -> PermissionRequestResult: """ Handle a permission request from the Copilot CLI. @@ -326,18 +491,18 @@ async def _handle_permission_request(self, request: dict) -> dict: if not handler: # No handler registered, deny permission - return {"kind": "denied-no-approval-rule-and-could-not-request-from-user"} + return PermissionRequestResult() try: result = handler(request, {"session_id": self.session_id}) if inspect.isawaitable(result): result = await result - return result + return cast(PermissionRequestResult, result) except Exception: # pylint: disable=broad-except # Handler failed, deny permission - return {"kind": "denied-no-approval-rule-and-could-not-request-from-user"} + return PermissionRequestResult() - def _register_user_input_handler(self, handler: Optional[UserInputHandler]) -> None: + def _register_user_input_handler(self, handler: UserInputHandler | None) -> None: """ Register a handler for user input requests. @@ -384,11 +549,11 @@ async def _handle_user_input_request(self, request: dict) -> UserInputResponse: ) if inspect.isawaitable(result): result = await result - return result + return cast(UserInputResponse, result) except Exception: raise - def _register_hooks(self, hooks: Optional[SessionHooks]) -> None: + def _register_hooks(self, hooks: SessionHooks | None) -> None: """ Register hook handlers for session lifecycle events. @@ -458,7 +623,7 @@ async def get_messages(self) -> list[SessionEvent]: A list of all session events in chronological order. Raises: - Exception: If the session has been destroyed or the connection fails. + Exception: If the session has been disconnected or the connection fails. Example: >>> events = await session.get_messages() @@ -471,20 +636,25 @@ async def get_messages(self) -> list[SessionEvent]: events_dicts = response["events"] return [session_event_from_dict(event_dict) for event_dict in events_dicts] - async def destroy(self) -> None: + async def disconnect(self) -> None: """ - Destroy this session and release all associated resources. + Disconnect this session and release all in-memory resources (event handlers, + tool handlers, permission handlers). + + Session state on disk (conversation history, planning state, artifacts) + is preserved, so the conversation can be resumed later by calling + :meth:`CopilotClient.resume_session` with the session ID. To + permanently remove all session data including files on disk, use + :meth:`CopilotClient.delete_session` instead. - After calling this method, the session can no longer be used. All event - handlers and tool handlers are cleared. To continue the conversation, - use :meth:`CopilotClient.resume_session` with the session ID. + After calling this method, the session object can no longer be used. Raises: Exception: If the connection fails. Example: - >>> # Clean up when done - >>> await session.destroy() + >>> # Clean up when done — session can still be resumed later + >>> await session.disconnect() """ await self._client.request("session.destroy", {"sessionId": self.session_id}) with self._event_handlers_lock: @@ -494,6 +664,34 @@ async def destroy(self) -> None: with self._permission_handler_lock: self._permission_handler = None + async def destroy(self) -> None: + """ + .. deprecated:: + Use :meth:`disconnect` instead. This method will be removed in a future release. + + Disconnect this session and release all in-memory resources. + Session data on disk is preserved for later resumption. + + Raises: + Exception: If the connection fails. + """ + import warnings + + warnings.warn( + "destroy() is deprecated, use disconnect() instead", + DeprecationWarning, + stacklevel=2, + ) + await self.disconnect() + + async def __aenter__(self) -> "CopilotSession": + """Enable use as an async context manager.""" + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Disconnect the session when exiting the context manager.""" + await self.disconnect() + async def abort(self) -> None: """ Abort the currently processing message in this session. @@ -502,7 +700,7 @@ async def abort(self) -> None: and can continue to be used for new messages. Raises: - Exception: If the session has been destroyed or the connection fails. + Exception: If the session has been disconnected or the connection fails. Example: >>> import asyncio @@ -517,3 +715,21 @@ async def abort(self) -> None: >>> await session.abort() """ await self._client.request("session.abort", {"sessionId": self.session_id}) + + async def set_model(self, model: str) -> None: + """ + Change the model for this session. + + The new model takes effect for the next message. Conversation history + is preserved. + + Args: + model: Model ID to switch to (e.g., "gpt-4.1", "claude-sonnet-4"). + + Raises: + Exception: If the session has been destroyed or the connection fails. + + Example: + >>> await session.set_model("gpt-4.1") + """ + await self.rpc.model.switch_to(SessionModelSwitchToParams(model_id=model)) diff --git a/python/copilot/tools.py b/python/copilot/tools.py index 43c1ed99e..573992cd5 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -9,7 +9,8 @@ import inspect import json -from typing import Any, Callable, TypeVar, get_type_hints, overload +from collections.abc import Callable +from typing import Any, TypeVar, get_type_hints, overload from pydantic import BaseModel @@ -24,6 +25,7 @@ def define_tool( name: str | None = None, *, description: str | None = None, + overrides_built_in_tool: bool = False, ) -> Callable[[Callable[..., Any]], Tool]: ... @@ -34,6 +36,7 @@ def define_tool( description: str | None = None, handler: Callable[[T, ToolInvocation], R], params_type: type[T], + overrides_built_in_tool: bool = False, ) -> Tool: ... @@ -43,6 +46,7 @@ def define_tool( description: str | None = None, handler: Callable[[Any, ToolInvocation], Any] | None = None, params_type: type[BaseModel] | None = None, + overrides_built_in_tool: bool = False, ) -> Tool | Callable[[Callable[[Any, ToolInvocation], Any]], Tool]: """ Define a tool with automatic JSON schema generation from Pydantic models. @@ -118,7 +122,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: # Build args based on detected signature call_args = [] if takes_params: - args = invocation["arguments"] or {} + args = invocation.arguments or {} if ptype is not None and _is_pydantic_model(ptype): call_args.append(ptype.model_validate(args)) else: @@ -137,11 +141,11 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: # Don't expose detailed error information to the LLM for security reasons. # The actual error is stored in the 'error' field for debugging. return ToolResult( - textResultForLlm="Invoking this tool produced an error. " + text_result_for_llm="Invoking this tool produced an error. " "Detailed information is not available.", - resultType="failure", + result_type="failure", error=str(exc), - toolTelemetry={}, + tool_telemetry={}, ) return Tool( @@ -149,6 +153,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: description=description or "", parameters=schema, handler=wrapped_handler, + overrides_built_in_tool=overrides_built_in_tool, ) # If handler is provided, call decorator immediately @@ -180,19 +185,19 @@ def _normalize_result(result: Any) -> ToolResult: """ if result is None: return ToolResult( - textResultForLlm="", - resultType="success", + text_result_for_llm="", + result_type="success", ) - # ToolResult passes through directly - if isinstance(result, dict) and "resultType" in result and "textResultForLlm" in result: + # ToolResult dataclass passes through directly + if isinstance(result, ToolResult): return result # Strings pass through directly if isinstance(result, str): return ToolResult( - textResultForLlm=result, - resultType="success", + text_result_for_llm=result, + result_type="success", ) # Everything else gets JSON-serialized (with Pydantic model support) @@ -207,6 +212,6 @@ def default(obj: Any) -> Any: raise TypeError(f"Failed to serialize tool result: {exc}") from exc return ToolResult( - textResultForLlm=json_str, - resultType="success", + text_result_for_llm=json_str, + result_type="success", ) diff --git a/python/copilot/types.py b/python/copilot/types.py index e89399777..6c484ce40 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -4,14 +4,15 @@ from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, Callable, Literal, TypedDict, Union - -from typing_extensions import NotRequired +from typing import Any, Literal, NotRequired, TypedDict # Import generated SessionEvent types -from .generated.session_events import SessionEvent +from .generated.session_events import ( + PermissionRequest, + SessionEvent, +) # SessionEvent is now imported from generated types # It provides proper type discrimination for all event types @@ -65,7 +66,7 @@ class SelectionAttachment(TypedDict): # Attachment type - union of all attachment types -Attachment = Union[FileAttachment, DirectoryAttachment, SelectionAttachment] +Attachment = FileAttachment | DirectoryAttachment | SelectionAttachment # Options for creating a CopilotClient @@ -102,32 +103,39 @@ class CopilotClientOptions(TypedDict, total=False): ToolResultType = Literal["success", "failure", "rejected", "denied"] -class ToolBinaryResult(TypedDict, total=False): - data: str - mimeType: str - type: str - description: str +@dataclass +class ToolBinaryResult: + """Binary content returned by a tool.""" + data: str = "" + mime_type: str = "" + type: str = "" + description: str = "" -class ToolResult(TypedDict, total=False): + +@dataclass +class ToolResult: """Result of a tool invocation.""" - textResultForLlm: str - binaryResultsForLlm: list[ToolBinaryResult] - resultType: ToolResultType - error: str - sessionLog: str - toolTelemetry: dict[str, Any] + text_result_for_llm: str = "" + result_type: ToolResultType = "success" + error: str | None = None + binary_results_for_llm: list[ToolBinaryResult] | None = None + session_log: str | None = None + tool_telemetry: dict[str, Any] | None = None -class ToolInvocation(TypedDict): - session_id: str - tool_call_id: str - tool_name: str - arguments: Any +@dataclass +class ToolInvocation: + """Context passed to a tool handler when invoked.""" + + session_id: str = "" + tool_call_id: str = "" + tool_name: str = "" + arguments: Any = None -ToolHandler = Callable[[ToolInvocation], Union[ToolResult, Awaitable[ToolResult]]] +ToolHandler = Callable[[ToolInvocation], ToolResult | Awaitable[ToolResult]] @dataclass @@ -136,6 +144,7 @@ class Tool: description: str handler: ToolHandler parameters: dict[str, Any] | None = None + overrides_built_in_tool: bool = False # System message configuration (discriminated union) @@ -162,40 +171,43 @@ class SystemMessageReplaceConfig(TypedDict): # Union type - use one or the other -SystemMessageConfig = Union[SystemMessageAppendConfig, SystemMessageReplaceConfig] +SystemMessageConfig = SystemMessageAppendConfig | SystemMessageReplaceConfig -# Permission request types -class PermissionRequest(TypedDict, total=False): - """Permission request from the server""" +# Permission result types - kind: Literal["shell", "write", "mcp", "read", "url"] - toolCallId: str - # Additional fields vary by kind +PermissionRequestResultKind = Literal[ + "approved", + "denied-by-rules", + "denied-by-content-exclusion-policy", + "denied-no-approval-rule-and-could-not-request-from-user", + "denied-interactively-by-user", +] -class PermissionRequestResult(TypedDict, total=False): - """Result of a permission request""" +@dataclass +class PermissionRequestResult: + """Result of a permission request.""" - kind: Literal[ - "approved", - "denied-by-rules", - "denied-no-approval-rule-and-could-not-request-from-user", - "denied-interactively-by-user", - ] - rules: list[Any] + kind: PermissionRequestResultKind = "denied-no-approval-rule-and-could-not-request-from-user" + rules: list[Any] | None = None + feedback: str | None = None + message: str | None = None + path: str | None = None _PermissionHandlerFn = Callable[ [PermissionRequest, dict[str, str]], - Union[PermissionRequestResult, Awaitable[PermissionRequestResult]], + PermissionRequestResult | Awaitable[PermissionRequestResult], ] class PermissionHandler: @staticmethod - def approve_all(request: Any, invocation: Any) -> dict: - return {"kind": "approved"} + def approve_all( + request: PermissionRequest, invocation: dict[str, str] + ) -> PermissionRequestResult: + return PermissionRequestResult(kind="approved") # ============================================================================ @@ -220,7 +232,7 @@ class UserInputResponse(TypedDict): UserInputHandler = Callable[ [UserInputRequest, dict[str, str]], - Union[UserInputResponse, Awaitable[UserInputResponse]], + UserInputResponse | Awaitable[UserInputResponse], ] @@ -257,7 +269,7 @@ class PreToolUseHookOutput(TypedDict, total=False): PreToolUseHandler = Callable[ [PreToolUseHookInput, dict[str, str]], - Union[PreToolUseHookOutput, None, Awaitable[Union[PreToolUseHookOutput, None]]], + PreToolUseHookOutput | None | Awaitable[PreToolUseHookOutput | None], ] @@ -281,7 +293,7 @@ class PostToolUseHookOutput(TypedDict, total=False): PostToolUseHandler = Callable[ [PostToolUseHookInput, dict[str, str]], - Union[PostToolUseHookOutput, None, Awaitable[Union[PostToolUseHookOutput, None]]], + PostToolUseHookOutput | None | Awaitable[PostToolUseHookOutput | None], ] @@ -303,11 +315,7 @@ class UserPromptSubmittedHookOutput(TypedDict, total=False): UserPromptSubmittedHandler = Callable[ [UserPromptSubmittedHookInput, dict[str, str]], - Union[ - UserPromptSubmittedHookOutput, - None, - Awaitable[Union[UserPromptSubmittedHookOutput, None]], - ], + UserPromptSubmittedHookOutput | None | Awaitable[UserPromptSubmittedHookOutput | None], ] @@ -329,7 +337,7 @@ class SessionStartHookOutput(TypedDict, total=False): SessionStartHandler = Callable[ [SessionStartHookInput, dict[str, str]], - Union[SessionStartHookOutput, None, Awaitable[Union[SessionStartHookOutput, None]]], + SessionStartHookOutput | None | Awaitable[SessionStartHookOutput | None], ] @@ -353,7 +361,7 @@ class SessionEndHookOutput(TypedDict, total=False): SessionEndHandler = Callable[ [SessionEndHookInput, dict[str, str]], - Union[SessionEndHookOutput, None, Awaitable[Union[SessionEndHookOutput, None]]], + SessionEndHookOutput | None | Awaitable[SessionEndHookOutput | None], ] @@ -378,7 +386,7 @@ class ErrorOccurredHookOutput(TypedDict, total=False): ErrorOccurredHandler = Callable[ [ErrorOccurredHookInput, dict[str, str]], - Union[ErrorOccurredHookOutput, None, Awaitable[Union[ErrorOccurredHookOutput, None]]], + ErrorOccurredHookOutput | None | Awaitable[ErrorOccurredHookOutput | None], ] @@ -420,7 +428,7 @@ class MCPRemoteServerConfig(TypedDict, total=False): headers: NotRequired[dict[str, str]] # HTTP headers -MCPServerConfig = Union[MCPLocalServerConfig, MCPRemoteServerConfig] +MCPServerConfig = MCPLocalServerConfig | MCPRemoteServerConfig # ============================================================================ @@ -625,11 +633,14 @@ def to_dict(self) -> dict: # Error information from client stop @dataclass -class StopError: - """Error information from client stop""" +class StopError(Exception): + """Error that occurred during client stop cleanup.""" message: str # Error message describing what failed during cleanup + def __post_init__(self) -> None: + Exception.__init__(self, self.message) + @staticmethod def from_dict(obj: Any) -> StopError: assert isinstance(obj, dict) diff --git a/python/e2e/test_agent_and_compact_rpc.py b/python/e2e/test_agent_and_compact_rpc.py new file mode 100644 index 000000000..cee6814f1 --- /dev/null +++ b/python/e2e/test_agent_and_compact_rpc.py @@ -0,0 +1,193 @@ +"""E2E tests for Agent Selection and Session Compaction RPC APIs.""" + +import pytest + +from copilot import CopilotClient, PermissionHandler +from copilot.generated.rpc import SessionAgentSelectParams + +from .testharness import CLI_PATH, E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestAgentSelectionRpc: + @pytest.mark.asyncio + async def test_should_list_available_custom_agents(self): + """Test listing available custom agents via RPC.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session( + { + "on_permission_request": PermissionHandler.approve_all, + "custom_agents": [ + { + "name": "test-agent", + "display_name": "Test Agent", + "description": "A test agent", + "prompt": "You are a test agent.", + }, + { + "name": "another-agent", + "display_name": "Another Agent", + "description": "Another test agent", + "prompt": "You are another agent.", + }, + ], + } + ) + + result = await session.rpc.agent.list() + assert result.agents is not None + assert len(result.agents) == 2 + assert result.agents[0].name == "test-agent" + assert result.agents[0].display_name == "Test Agent" + assert result.agents[0].description == "A test agent" + assert result.agents[1].name == "another-agent" + + await session.disconnect() + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_should_return_null_when_no_agent_is_selected(self): + """Test getCurrent returns null when no agent is selected.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session( + { + "on_permission_request": PermissionHandler.approve_all, + "custom_agents": [ + { + "name": "test-agent", + "display_name": "Test Agent", + "description": "A test agent", + "prompt": "You are a test agent.", + } + ], + } + ) + + result = await session.rpc.agent.get_current() + assert result.agent is None + + await session.disconnect() + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_should_select_and_get_current_agent(self): + """Test selecting an agent and verifying getCurrent returns it.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session( + { + "on_permission_request": PermissionHandler.approve_all, + "custom_agents": [ + { + "name": "test-agent", + "display_name": "Test Agent", + "description": "A test agent", + "prompt": "You are a test agent.", + } + ], + } + ) + + # Select the agent + select_result = await session.rpc.agent.select( + SessionAgentSelectParams(name="test-agent") + ) + assert select_result.agent is not None + assert select_result.agent.name == "test-agent" + assert select_result.agent.display_name == "Test Agent" + + # Verify getCurrent returns the selected agent + current_result = await session.rpc.agent.get_current() + assert current_result.agent is not None + assert current_result.agent.name == "test-agent" + + await session.disconnect() + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_should_deselect_current_agent(self): + """Test deselecting the current agent.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session( + { + "on_permission_request": PermissionHandler.approve_all, + "custom_agents": [ + { + "name": "test-agent", + "display_name": "Test Agent", + "description": "A test agent", + "prompt": "You are a test agent.", + } + ], + } + ) + + # Select then deselect + await session.rpc.agent.select(SessionAgentSelectParams(name="test-agent")) + await session.rpc.agent.deselect() + + # Verify no agent is selected + current_result = await session.rpc.agent.get_current() + assert current_result.agent is None + + await session.disconnect() + await client.stop() + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_should_return_empty_list_when_no_custom_agents_configured(self): + """Test listing agents returns empty when none configured.""" + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + + result = await session.rpc.agent.list() + assert result.agents == [] + + await session.disconnect() + await client.stop() + finally: + await client.force_stop() + + +class TestSessionCompactionRpc: + @pytest.mark.asyncio + async def test_should_compact_session_history_after_messages(self, ctx: E2ETestContext): + """Test compacting session history via RPC.""" + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + + # Send a message to create some history + await session.send_and_wait({"prompt": "What is 2+2?"}) + + # Compact the session + result = await session.rpc.compaction.compact() + assert isinstance(result.success, bool) + assert isinstance(result.tokens_removed, (int, float)) + assert isinstance(result.messages_removed, (int, float)) + + await session.disconnect() diff --git a/python/e2e/test_ask_user.py b/python/e2e/test_ask_user.py index 93036ea4c..bddc062df 100644 --- a/python/e2e/test_ask_user.py +++ b/python/e2e/test_ask_user.py @@ -4,6 +4,8 @@ import pytest +from copilot import PermissionHandler + from .testharness import E2ETestContext pytestmark = pytest.mark.asyncio(loop_scope="module") @@ -27,7 +29,12 @@ async def on_user_input_request(request, invocation): "wasFreeform": not bool(choices), } - session = await ctx.client.create_session({"on_user_input_request": on_user_input_request}) + session = await ctx.client.create_session( + { + "on_user_input_request": on_user_input_request, + "on_permission_request": PermissionHandler.approve_all, + } + ) await session.send_and_wait( { @@ -46,7 +53,7 @@ async def on_user_input_request(request, invocation): req.get("question") and len(req.get("question")) > 0 for req in user_input_requests ) - await session.destroy() + await session.disconnect() async def test_should_receive_choices_in_user_input_request(self, ctx: E2ETestContext): """Test that choices are received in user input request""" @@ -61,7 +68,12 @@ async def on_user_input_request(request, invocation): "wasFreeform": False, } - session = await ctx.client.create_session({"on_user_input_request": on_user_input_request}) + session = await ctx.client.create_session( + { + "on_user_input_request": on_user_input_request, + "on_permission_request": PermissionHandler.approve_all, + } + ) await session.send_and_wait( { @@ -82,7 +94,7 @@ async def on_user_input_request(request, invocation): ) assert request_with_choices is not None - await session.destroy() + await session.disconnect() async def test_should_handle_freeform_user_input_response(self, ctx: E2ETestContext): """Test that freeform user input responses work""" @@ -97,7 +109,12 @@ async def on_user_input_request(request, invocation): "wasFreeform": True, } - session = await ctx.client.create_session({"on_user_input_request": on_user_input_request}) + session = await ctx.client.create_session( + { + "on_user_input_request": on_user_input_request, + "on_permission_request": PermissionHandler.approve_all, + } + ) response = await session.send_and_wait( { @@ -115,4 +132,4 @@ async def on_user_input_request(request, invocation): # (This is a soft check since the model may paraphrase) assert response is not None - await session.destroy() + await session.disconnect() diff --git a/python/e2e/test_client.py b/python/e2e/test_client.py index c18764e55..1f7c76c04 100644 --- a/python/e2e/test_client.py +++ b/python/e2e/test_client.py @@ -2,7 +2,7 @@ import pytest -from copilot import CopilotClient +from copilot import CopilotClient, PermissionHandler, StopError from .testharness import CLI_PATH @@ -20,8 +20,7 @@ async def test_should_start_and_connect_to_server_using_stdio(self): assert pong.message == "pong: test message" assert pong.timestamp >= 0 - errors = await client.stop() - assert len(errors) == 0 + await client.stop() assert client.get_state() == "disconnected" finally: await client.force_stop() @@ -38,20 +37,19 @@ async def test_should_start_and_connect_to_server_using_tcp(self): assert pong.message == "pong: test message" assert pong.timestamp >= 0 - errors = await client.stop() - assert len(errors) == 0 + await client.stop() assert client.get_state() == "disconnected" finally: await client.force_stop() @pytest.mark.asyncio - async def test_should_return_errors_on_failed_cleanup(self): + async def test_should_raise_exception_group_on_failed_cleanup(self): import asyncio client = CopilotClient({"cli_path": CLI_PATH}) try: - await client.create_session() + await client.create_session({"on_permission_request": PermissionHandler.approve_all}) # Kill the server process to force cleanup to fail process = client._process @@ -59,9 +57,11 @@ async def test_should_return_errors_on_failed_cleanup(self): process.kill() await asyncio.sleep(0.1) - errors = await client.stop() - assert len(errors) > 0 - assert "Failed to destroy session" in errors[0].message + with pytest.raises(ExceptionGroup) as exc_info: + await client.stop() + assert len(exc_info.value.exceptions) > 0 + assert isinstance(exc_info.value.exceptions[0], StopError) + assert "Failed to disconnect session" in exc_info.value.exceptions[0].message finally: await client.force_stop() @@ -69,7 +69,7 @@ async def test_should_return_errors_on_failed_cleanup(self): async def test_should_force_stop_without_cleanup(self): client = CopilotClient({"cli_path": CLI_PATH}) - await client.create_session() + await client.create_session({"on_permission_request": PermissionHandler.approve_all}) await client.force_stop() assert client.get_state() == "disconnected" @@ -206,7 +206,9 @@ async def test_should_report_error_with_stderr_when_cli_fails_to_start(self): # Verify subsequent calls also fail (don't hang) with pytest.raises(Exception) as exc_info2: - session = await client.create_session() + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) await session.send("test") # Error message varies by platform (EINVAL on Windows, EPIPE on Linux) error_msg = str(exc_info2.value).lower() diff --git a/python/e2e/test_compaction.py b/python/e2e/test_compaction.py index b2463e447..5447b4bad 100644 --- a/python/e2e/test_compaction.py +++ b/python/e2e/test_compaction.py @@ -2,6 +2,7 @@ import pytest +from copilot import PermissionHandler from copilot.generated.session_events import SessionEventType from .testharness import E2ETestContext @@ -23,7 +24,8 @@ async def test_should_trigger_compaction_with_low_threshold_and_emit_events( "background_compaction_threshold": 0.005, # Block at 1% to ensure compaction runs "buffer_exhaustion_threshold": 0.01, - } + }, + "on_permission_request": PermissionHandler.approve_all, } ) @@ -39,9 +41,7 @@ def on_event(event): session.on(on_event) # Send multiple messages to fill up the context window - await session.send_and_wait( - {"prompt": "Tell me a long story about a dragon. Be very detailed."} - ) + await session.send_and_wait({"prompt": "Tell me a story about a dragon. Be detailed."}) await session.send_and_wait( {"prompt": "Continue the story with more details about the dragon's castle."} ) @@ -71,7 +71,12 @@ def on_event(event): async def test_should_not_emit_compaction_events_when_infinite_sessions_disabled( self, ctx: E2ETestContext ): - session = await ctx.client.create_session({"infinite_sessions": {"enabled": False}}) + session = await ctx.client.create_session( + { + "infinite_sessions": {"enabled": False}, + "on_permission_request": PermissionHandler.approve_all, + } + ) compaction_events = [] diff --git a/python/e2e/test_hooks.py b/python/e2e/test_hooks.py index 8278fb33c..c886c6e27 100644 --- a/python/e2e/test_hooks.py +++ b/python/e2e/test_hooks.py @@ -43,7 +43,7 @@ async def on_pre_tool_use(input_data, invocation): # Should have received the tool name assert any(inp.get("toolName") for inp in pre_tool_use_inputs) - await session.destroy() + await session.disconnect() async def test_should_invoke_posttooluse_hook_after_model_runs_a_tool( self, ctx: E2ETestContext @@ -77,7 +77,7 @@ async def on_post_tool_use(input_data, invocation): assert any(inp.get("toolName") for inp in post_tool_use_inputs) assert any(inp.get("toolResult") is not None for inp in post_tool_use_inputs) - await session.destroy() + await session.disconnect() async def test_should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call( self, ctx: E2ETestContext @@ -118,7 +118,7 @@ async def on_post_tool_use(input_data, invocation): common_tool = next((name for name in pre_tool_names if name in post_tool_names), None) assert common_tool is not None - await session.destroy() + await session.disconnect() async def test_should_deny_tool_execution_when_pretooluse_returns_deny( self, ctx: E2ETestContext @@ -153,4 +153,4 @@ async def on_pre_tool_use(input_data, invocation): # At minimum, we verify the hook was invoked assert response is not None - await session.destroy() + await session.disconnect() diff --git a/python/e2e/test_mcp_and_agents.py b/python/e2e/test_mcp_and_agents.py index 7ca4b8c2b..fd99cc2c3 100644 --- a/python/e2e/test_mcp_and_agents.py +++ b/python/e2e/test_mcp_and_agents.py @@ -32,7 +32,9 @@ async def test_should_accept_mcp_server_configuration_on_session_create( } } - session = await ctx.client.create_session({"mcp_servers": mcp_servers}) + session = await ctx.client.create_session( + {"mcp_servers": mcp_servers, "on_permission_request": PermissionHandler.approve_all} + ) assert session.session_id is not None @@ -41,14 +43,16 @@ async def test_should_accept_mcp_server_configuration_on_session_create( assert message is not None assert "4" in message.data.content - await session.destroy() + await session.disconnect() async def test_should_accept_mcp_server_configuration_on_session_resume( self, ctx: E2ETestContext ): """Test that MCP server configuration is accepted on session resume""" # Create a session first - session1 = await ctx.client.create_session() + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) session_id = session1.session_id await session1.send_and_wait({"prompt": "What is 1+1?"}) @@ -62,7 +66,10 @@ async def test_should_accept_mcp_server_configuration_on_session_resume( } } - session2 = await ctx.client.resume_session(session_id, {"mcp_servers": mcp_servers}) + session2 = await ctx.client.resume_session( + session_id, + {"mcp_servers": mcp_servers, "on_permission_request": PermissionHandler.approve_all}, + ) assert session2.session_id == session_id @@ -70,7 +77,7 @@ async def test_should_accept_mcp_server_configuration_on_session_resume( assert message is not None assert "6" in message.data.content - await session2.destroy() + await session2.disconnect() async def test_should_pass_literal_env_values_to_mcp_server_subprocess( self, ctx: E2ETestContext @@ -105,7 +112,7 @@ async def test_should_pass_literal_env_values_to_mcp_server_subprocess( assert message is not None assert "hunter2" in message.data.content - await session.destroy() + await session.disconnect() class TestCustomAgents: @@ -123,7 +130,9 @@ async def test_should_accept_custom_agent_configuration_on_session_create( } ] - session = await ctx.client.create_session({"custom_agents": custom_agents}) + session = await ctx.client.create_session( + {"custom_agents": custom_agents, "on_permission_request": PermissionHandler.approve_all} + ) assert session.session_id is not None @@ -132,14 +141,16 @@ async def test_should_accept_custom_agent_configuration_on_session_create( assert message is not None assert "10" in message.data.content - await session.destroy() + await session.disconnect() async def test_should_accept_custom_agent_configuration_on_session_resume( self, ctx: E2ETestContext ): """Test that custom agent configuration is accepted on session resume""" # Create a session first - session1 = await ctx.client.create_session() + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) session_id = session1.session_id await session1.send_and_wait({"prompt": "What is 1+1?"}) @@ -153,7 +164,13 @@ async def test_should_accept_custom_agent_configuration_on_session_resume( } ] - session2 = await ctx.client.resume_session(session_id, {"custom_agents": custom_agents}) + session2 = await ctx.client.resume_session( + session_id, + { + "custom_agents": custom_agents, + "on_permission_request": PermissionHandler.approve_all, + }, + ) assert session2.session_id == session_id @@ -161,7 +178,7 @@ async def test_should_accept_custom_agent_configuration_on_session_resume( assert message is not None assert "12" in message.data.content - await session2.destroy() + await session2.disconnect() class TestCombinedConfiguration: @@ -186,7 +203,11 @@ async def test_should_accept_both_mcp_servers_and_custom_agents(self, ctx: E2ETe ] session = await ctx.client.create_session( - {"mcp_servers": mcp_servers, "custom_agents": custom_agents} + { + "mcp_servers": mcp_servers, + "custom_agents": custom_agents, + "on_permission_request": PermissionHandler.approve_all, + } ) assert session.session_id is not None @@ -195,4 +216,4 @@ async def test_should_accept_both_mcp_servers_and_custom_agents(self, ctx: E2ETe message = await get_final_assistant_message(session) assert "14" in message.data.content - await session.destroy() + await session.disconnect() diff --git a/python/e2e/test_multi_client.py b/python/e2e/test_multi_client.py new file mode 100644 index 000000000..caf58cd55 --- /dev/null +++ b/python/e2e/test_multi_client.py @@ -0,0 +1,461 @@ +"""E2E Multi-Client Broadcast Tests + +Tests that verify the protocol v3 broadcast model works correctly when +multiple clients are connected to the same CLI server session. +""" + +import asyncio +import os +import shutil +import tempfile + +import pytest +import pytest_asyncio +from pydantic import BaseModel, Field + +from copilot import ( + CopilotClient, + PermissionHandler, + PermissionRequestResult, + ToolInvocation, + define_tool, +) + +from .testharness import get_final_assistant_message +from .testharness.proxy import CapiProxy + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class MultiClientContext: + """Extended test context that manages two clients connected to the same CLI server.""" + + def __init__(self): + self.cli_path: str = "" + self.home_dir: str = "" + self.work_dir: str = "" + self.proxy_url: str = "" + self._proxy: CapiProxy | None = None + self._client1: CopilotClient | None = None + self._client2: CopilotClient | None = None + + async def setup(self): + from .testharness.context import get_cli_path_for_tests + + self.cli_path = get_cli_path_for_tests() + self.home_dir = tempfile.mkdtemp(prefix="copilot-multi-config-") + self.work_dir = tempfile.mkdtemp(prefix="copilot-multi-work-") + + self._proxy = CapiProxy() + self.proxy_url = await self._proxy.start() + + github_token = ( + "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None + ) + + # Client 1 uses TCP mode so a second client can connect to the same server + opts: dict = { + "cli_path": self.cli_path, + "cwd": self.work_dir, + "env": self.get_env(), + "use_stdio": False, + } + if github_token: + opts["github_token"] = github_token + self._client1 = CopilotClient(opts) + + # Trigger connection by creating and disconnecting an init session + init_session = await self._client1.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + await init_session.disconnect() + + # Read the actual port from client 1 and create client 2 + actual_port = self._client1.actual_port + assert actual_port is not None, "Client 1 should have an actual port after connecting" + + self._client2 = CopilotClient({"cli_url": f"localhost:{actual_port}"}) + + async def teardown(self, test_failed: bool = False): + if self._client2: + try: + await self._client2.stop() + except Exception: + pass + self._client2 = None + + if self._client1: + try: + await self._client1.stop() + except Exception: + pass + self._client1 = None + + if self._proxy: + await self._proxy.stop(skip_writing_cache=test_failed) + self._proxy = None + + if self.home_dir and os.path.exists(self.home_dir): + shutil.rmtree(self.home_dir, ignore_errors=True) + if self.work_dir and os.path.exists(self.work_dir): + shutil.rmtree(self.work_dir, ignore_errors=True) + + async def configure_for_test(self, test_file: str, test_name: str): + import re + + sanitized_name = re.sub(r"[^a-zA-Z0-9]", "_", test_name).lower() + # Use the same snapshot directory structure as the standard context + from .testharness.context import SNAPSHOTS_DIR + + snapshot_path = SNAPSHOTS_DIR / test_file / f"{sanitized_name}.yaml" + abs_snapshot_path = str(snapshot_path.resolve()) + + if self._proxy: + await self._proxy.configure(abs_snapshot_path, self.work_dir) + + # Clear temp directories between tests + from pathlib import Path + + for item in Path(self.home_dir).iterdir(): + if item.is_dir(): + shutil.rmtree(item, ignore_errors=True) + else: + item.unlink(missing_ok=True) + for item in Path(self.work_dir).iterdir(): + if item.is_dir(): + shutil.rmtree(item, ignore_errors=True) + else: + item.unlink(missing_ok=True) + + def get_env(self) -> dict: + env = os.environ.copy() + env.update( + { + "COPILOT_API_URL": self.proxy_url, + "XDG_CONFIG_HOME": self.home_dir, + "XDG_STATE_HOME": self.home_dir, + } + ) + return env + + @property + def client1(self) -> CopilotClient: + if not self._client1: + raise RuntimeError("Context not set up") + return self._client1 + + @property + def client2(self) -> CopilotClient: + if not self._client2: + raise RuntimeError("Context not set up") + return self._client2 + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + rep = outcome.get_result() + if rep.when == "call" and rep.failed: + item.session.stash.setdefault("any_test_failed", False) + item.session.stash["any_test_failed"] = True + + +@pytest_asyncio.fixture(scope="module", loop_scope="module") +async def mctx(request): + """Multi-client test context fixture.""" + context = MultiClientContext() + await context.setup() + yield context + any_failed = request.session.stash.get("any_test_failed", False) + await context.teardown(test_failed=any_failed) + + +@pytest_asyncio.fixture(autouse=True, loop_scope="module") +async def configure_multi_test(request, mctx): + """Automatically configure the proxy for each test.""" + module_name = request.module.__name__.split(".")[-1] + test_file = module_name[5:] if module_name.startswith("test_") else module_name + test_name = request.node.name + if test_name.startswith("test_"): + test_name = test_name[5:] + await mctx.configure_for_test(test_file, test_name) + yield + + +class TestMultiClientBroadcast: + async def test_both_clients_see_tool_request_and_completion_events( + self, mctx: MultiClientContext + ): + """Both clients see tool request and completion events.""" + + class SeedParams(BaseModel): + seed: str = Field(description="A seed value") + + @define_tool("magic_number", description="Returns a magic number") + def magic_number(params: SeedParams, invocation: ToolInvocation) -> str: + return f"MAGIC_{params.seed}_42" + + # Client 1 creates a session with a custom tool + session1 = await mctx.client1.create_session( + {"on_permission_request": PermissionHandler.approve_all, "tools": [magic_number]} + ) + + # Client 2 resumes with NO tools — should not overwrite client 1's tools + session2 = await mctx.client2.resume_session( + session1.session_id, {"on_permission_request": PermissionHandler.approve_all} + ) + + # Track events seen by each client + client1_events = [] + client2_events = [] + session1.on(lambda event: client1_events.append(event)) + session2.on(lambda event: client2_events.append(event)) + + # Send a prompt that triggers the custom tool + await session1.send( + {"prompt": "Use the magic_number tool with seed 'hello' and tell me the result"} + ) + response = await get_final_assistant_message(session1) + assert "MAGIC_hello_42" in (response.data.content or "") + + # Both clients should have seen the external_tool.requested event + c1_tool_requested = [e for e in client1_events if e.type.value == "external_tool.requested"] + c2_tool_requested = [e for e in client2_events if e.type.value == "external_tool.requested"] + assert len(c1_tool_requested) > 0 + assert len(c2_tool_requested) > 0 + + # Both clients should have seen the external_tool.completed event + c1_tool_completed = [e for e in client1_events if e.type.value == "external_tool.completed"] + c2_tool_completed = [e for e in client2_events if e.type.value == "external_tool.completed"] + assert len(c1_tool_completed) > 0 + assert len(c2_tool_completed) > 0 + + await session2.disconnect() + + async def test_one_client_approves_permission_and_both_see_the_result( + self, mctx: MultiClientContext + ): + """One client approves a permission request and both see the result.""" + permission_requests = [] + + # Client 1 creates a session and manually approves permission requests + session1 = await mctx.client1.create_session( + { + "on_permission_request": lambda request, invocation: ( + permission_requests.append(request) or PermissionRequestResult(kind="approved") + ), + } + ) + + # Client 2 resumes — its handler never resolves, so only client 1's approval takes effect + session2 = await mctx.client2.resume_session( + session1.session_id, + {"on_permission_request": lambda request, invocation: asyncio.Future()}, + ) + + client1_events = [] + client2_events = [] + session1.on(lambda event: client1_events.append(event)) + session2.on(lambda event: client2_events.append(event)) + + # Send a prompt that triggers a write operation (requires permission) + await session1.send( + {"prompt": "Create a file called hello.txt containing the text 'hello world'"} + ) + response = await get_final_assistant_message(session1) + assert response.data.content + + # Client 1 should have handled permission requests + assert len(permission_requests) > 0 + + # Both clients should have seen permission.requested events + c1_perm_requested = [e for e in client1_events if e.type.value == "permission.requested"] + c2_perm_requested = [e for e in client2_events if e.type.value == "permission.requested"] + assert len(c1_perm_requested) > 0 + assert len(c2_perm_requested) > 0 + + # Both clients should have seen permission.completed events with approved result + c1_perm_completed = [e for e in client1_events if e.type.value == "permission.completed"] + c2_perm_completed = [e for e in client2_events if e.type.value == "permission.completed"] + assert len(c1_perm_completed) > 0 + assert len(c2_perm_completed) > 0 + for event in c1_perm_completed + c2_perm_completed: + assert event.data.result.kind.value == "approved" + + await session2.disconnect() + + async def test_one_client_rejects_permission_and_both_see_the_result( + self, mctx: MultiClientContext + ): + """One client rejects a permission request and both see the result.""" + # Client 1 creates a session and denies all permission requests + session1 = await mctx.client1.create_session( + { + "on_permission_request": lambda request, invocation: PermissionRequestResult( + kind="denied-interactively-by-user" + ), + } + ) + + # Client 2 resumes — its handler never resolves + session2 = await mctx.client2.resume_session( + session1.session_id, + {"on_permission_request": lambda request, invocation: asyncio.Future()}, + ) + + client1_events = [] + client2_events = [] + session1.on(lambda event: client1_events.append(event)) + session2.on(lambda event: client2_events.append(event)) + + # Create a file that the agent will try to edit + test_file = os.path.join(mctx.work_dir, "protected.txt") + with open(test_file, "w") as f: + f.write("protected content") + + await session1.send({"prompt": "Edit protected.txt and replace 'protected' with 'hacked'."}) + await get_final_assistant_message(session1) + + # Verify the file was NOT modified (permission was denied) + with open(test_file) as f: + content = f.read() + assert content == "protected content" + + # Both clients should have seen permission.requested and permission.completed + c1_perm_requested = [e for e in client1_events if e.type.value == "permission.requested"] + c2_perm_requested = [e for e in client2_events if e.type.value == "permission.requested"] + assert len(c1_perm_requested) > 0 + assert len(c2_perm_requested) > 0 + + # Both clients should see the denial + c1_perm_completed = [e for e in client1_events if e.type.value == "permission.completed"] + c2_perm_completed = [e for e in client2_events if e.type.value == "permission.completed"] + assert len(c1_perm_completed) > 0 + assert len(c2_perm_completed) > 0 + for event in c1_perm_completed + c2_perm_completed: + assert event.data.result.kind.value == "denied-interactively-by-user" + + await session2.disconnect() + + @pytest.mark.timeout(90) + async def test_two_clients_register_different_tools_and_agent_uses_both( + self, mctx: MultiClientContext + ): + """Two clients register different tools and agent uses both.""" + + class CountryCodeParams(BaseModel): + model_config = {"populate_by_name": True} + country_code: str = Field(alias="countryCode", description="A two-letter country code") + + @define_tool("city_lookup", description="Returns a city name for a given country code") + def city_lookup(params: CountryCodeParams, invocation: ToolInvocation) -> str: + return f"CITY_FOR_{params.country_code}" + + @define_tool("currency_lookup", description="Returns a currency for a given country code") + def currency_lookup(params: CountryCodeParams, invocation: ToolInvocation) -> str: + return f"CURRENCY_FOR_{params.country_code}" + + # Client 1 creates a session with tool A + session1 = await mctx.client1.create_session( + {"on_permission_request": PermissionHandler.approve_all, "tools": [city_lookup]} + ) + + # Client 2 resumes with tool B (different tool, union should have both) + session2 = await mctx.client2.resume_session( + session1.session_id, + {"on_permission_request": PermissionHandler.approve_all, "tools": [currency_lookup]}, + ) + + # Send prompts sequentially to avoid nondeterministic tool_call ordering + await session1.send( + {"prompt": "Use the city_lookup tool with countryCode 'US' and tell me the result."} + ) + response1 = await get_final_assistant_message(session1) + assert "CITY_FOR_US" in (response1.data.content or "") + + await session1.send( + { + "prompt": ( + "Now use the currency_lookup tool with countryCode 'US' and tell me the result." + ) + } + ) + response2 = await get_final_assistant_message(session1) + assert "CURRENCY_FOR_US" in (response2.data.content or "") + + await session2.disconnect() + + @pytest.mark.timeout(90) + @pytest.mark.skip( + reason="Flaky on CI: Python TCP socket close detection is too slow for snapshot replay" + ) + async def test_disconnecting_client_removes_its_tools(self, mctx: MultiClientContext): + """Disconnecting a client removes its tools from the session.""" + + class InputParams(BaseModel): + input: str = Field(description="Input value") + + @define_tool("stable_tool", description="A tool that persists across disconnects") + def stable_tool(params: InputParams, invocation: ToolInvocation) -> str: + return f"STABLE_{params.input}" + + @define_tool( + "ephemeral_tool", + description="A tool that will disappear when its client disconnects", + ) + def ephemeral_tool(params: InputParams, invocation: ToolInvocation) -> str: + return f"EPHEMERAL_{params.input}" + + # Client 1 creates a session with stable_tool + session1 = await mctx.client1.create_session( + {"on_permission_request": PermissionHandler.approve_all, "tools": [stable_tool]} + ) + + # Client 2 resumes with ephemeral_tool + await mctx.client2.resume_session( + session1.session_id, + {"on_permission_request": PermissionHandler.approve_all, "tools": [ephemeral_tool]}, + ) + + # Verify both tools work before disconnect. + # Sequential prompts avoid nondeterministic tool_call ordering. + await session1.send( + { + "prompt": "Use the stable_tool with input 'test1' and tell me the result.", + } + ) + stable_response = await get_final_assistant_message(session1) + assert "STABLE_test1" in (stable_response.data.content or "") + + await session1.send( + { + "prompt": "Use the ephemeral_tool with input 'test2' and tell me the result.", + } + ) + ephemeral_response = await get_final_assistant_message(session1) + assert "EPHEMERAL_test2" in (ephemeral_response.data.content or "") + + # Force disconnect client 2 without destroying the shared session + await mctx.client2.force_stop() + + # Give the server time to process the connection close and remove tools + await asyncio.sleep(0.5) + + # Recreate client2 for future tests (but don't rejoin the session) + actual_port = mctx.client1.actual_port + mctx._client2 = CopilotClient({"cli_url": f"localhost:{actual_port}"}) + + # Now only stable_tool should be available + await session1.send( + { + "prompt": ( + "Use the stable_tool with input 'still_here'." + " Also try using ephemeral_tool" + " if it is available." + ) + } + ) + after_response = await get_final_assistant_message(session1) + assert "STABLE_still_here" in (after_response.data.content or "") + # ephemeral_tool should NOT have produced a result + assert "EPHEMERAL_" not in (after_response.data.content or "") diff --git a/python/e2e/test_permissions.py b/python/e2e/test_permissions.py index 80b69ebba..609003e87 100644 --- a/python/e2e/test_permissions.py +++ b/python/e2e/test_permissions.py @@ -24,8 +24,7 @@ def on_permission_request( ) -> PermissionRequestResult: permission_requests.append(request) assert invocation["session_id"] == session.session_id - # Approve the permission - return {"kind": "approved"} + return PermissionRequestResult(kind="approved") session = await ctx.client.create_session({"on_permission_request": on_permission_request}) @@ -39,10 +38,10 @@ def on_permission_request( assert len(permission_requests) > 0 # Should include write permission request - write_requests = [req for req in permission_requests if req.get("kind") == "write"] + write_requests = [req for req in permission_requests if req.kind.value == "write"] assert len(write_requests) > 0 - await session.destroy() + await session.disconnect() async def test_should_deny_permission_when_handler_returns_denied(self, ctx: E2ETestContext): """Test denying permissions""" @@ -50,8 +49,7 @@ async def test_should_deny_permission_when_handler_returns_denied(self, ctx: E2E def on_permission_request( request: PermissionRequest, invocation: dict ) -> PermissionRequestResult: - # Deny all permissions - return {"kind": "denied-interactively-by-user"} + return PermissionRequestResult(kind="denied-interactively-by-user") session = await ctx.client.create_session({"on_permission_request": on_permission_request}) @@ -66,12 +64,17 @@ def on_permission_request( content = read_file(ctx.work_dir, "protected.txt") assert content == original_content - await session.destroy() + await session.disconnect() - async def test_should_deny_tool_operations_by_default_when_no_handler_is_provided( + async def test_should_deny_tool_operations_when_handler_explicitly_denies( self, ctx: E2ETestContext ): - session = await ctx.client.create_session() + """Test that tool operations are denied when handler explicitly denies""" + + def deny_all(request, invocation): + return PermissionRequestResult() + + session = await ctx.client.create_session({"on_permission_request": deny_all}) denied_events = [] done_event = asyncio.Event() @@ -96,18 +99,22 @@ def on_event(event): assert len(denied_events) > 0 - await session.destroy() + await session.disconnect() - async def test_should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume( + async def test_should_deny_tool_operations_when_handler_explicitly_denies_after_resume( self, ctx: E2ETestContext ): + """Test that tool operations are denied after resume when handler explicitly denies""" session1 = await ctx.client.create_session( {"on_permission_request": PermissionHandler.approve_all} ) session_id = session1.session_id await session1.send_and_wait({"prompt": "What is 1+1?"}) - session2 = await ctx.client.resume_session(session_id) + def deny_all(request, invocation): + return PermissionRequestResult() + + session2 = await ctx.client.resume_session(session_id, {"on_permission_request": deny_all}) denied_events = [] done_event = asyncio.Event() @@ -132,21 +139,20 @@ def on_event(event): assert len(denied_events) > 0 - await session2.destroy() + await session2.disconnect() - async def test_should_work_without_permission_handler__default_behavior_( - self, ctx: E2ETestContext - ): - """Test that sessions work without permission handler (default behavior)""" - # Create session without on_permission_request handler - session = await ctx.client.create_session() + async def test_should_work_with_approve_all_permission_handler(self, ctx: E2ETestContext): + """Test that sessions work with approve-all permission handler""" + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) message = await session.send_and_wait({"prompt": "What is 2+2?"}) assert message is not None assert "4" in message.data.content - await session.destroy() + await session.disconnect() async def test_should_handle_async_permission_handler(self, ctx: E2ETestContext): """Test async permission handler""" @@ -158,7 +164,7 @@ async def on_permission_request( permission_requests.append(request) # Simulate async permission check (e.g., user prompt) await asyncio.sleep(0.01) - return {"kind": "approved"} + return PermissionRequestResult(kind="approved") session = await ctx.client.create_session({"on_permission_request": on_permission_request}) @@ -166,14 +172,16 @@ async def on_permission_request( assert len(permission_requests) > 0 - await session.destroy() + await session.disconnect() async def test_should_resume_session_with_permission_handler(self, ctx: E2ETestContext): """Test resuming session with permission handler""" permission_requests = [] - # Create session without permission handler - session1 = await ctx.client.create_session() + # Create initial session + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) session_id = session1.session_id await session1.send_and_wait({"prompt": "What is 1+1?"}) @@ -182,7 +190,7 @@ def on_permission_request( request: PermissionRequest, invocation: dict ) -> PermissionRequestResult: permission_requests.append(request) - return {"kind": "approved"} + return PermissionRequestResult(kind="approved") session2 = await ctx.client.resume_session( session_id, {"on_permission_request": on_permission_request} @@ -193,7 +201,7 @@ def on_permission_request( # Should have permission requests from resumed session assert len(permission_requests) > 0 - await session2.destroy() + await session2.disconnect() async def test_should_handle_permission_handler_errors_gracefully(self, ctx: E2ETestContext): """Test that permission handler errors are handled gracefully""" @@ -214,7 +222,7 @@ def on_permission_request( content_lower = message.data.content.lower() assert any(word in content_lower for word in ["fail", "cannot", "unable", "permission"]) - await session.destroy() + await session.disconnect() async def test_should_receive_toolcallid_in_permission_requests(self, ctx: E2ETestContext): """Test that toolCallId is included in permission requests""" @@ -224,11 +232,11 @@ def on_permission_request( request: PermissionRequest, invocation: dict ) -> PermissionRequestResult: nonlocal received_tool_call_id - if request.get("toolCallId"): + if request.tool_call_id: received_tool_call_id = True - assert isinstance(request["toolCallId"], str) - assert len(request["toolCallId"]) > 0 - return {"kind": "approved"} + assert isinstance(request.tool_call_id, str) + assert len(request.tool_call_id) > 0 + return PermissionRequestResult(kind="approved") session = await ctx.client.create_session({"on_permission_request": on_permission_request}) @@ -236,4 +244,4 @@ def on_permission_request( assert received_tool_call_id - await session.destroy() + await session.disconnect() diff --git a/python/e2e/test_rpc.py b/python/e2e/test_rpc.py index da2ba3eb6..1b455d632 100644 --- a/python/e2e/test_rpc.py +++ b/python/e2e/test_rpc.py @@ -2,7 +2,7 @@ import pytest -from copilot import CopilotClient +from copilot import CopilotClient, PermissionHandler from copilot.generated.rpc import PingParams from .testharness import CLI_PATH, E2ETestContext @@ -77,7 +77,9 @@ class TestSessionRpc: @pytest.mark.skip(reason="session.model.getCurrent not yet implemented in CLI") async def test_should_call_session_rpc_model_get_current(self, ctx: E2ETestContext): """Test calling session.rpc.model.getCurrent""" - session = await ctx.client.create_session({"model": "claude-sonnet-4.5"}) + session = await ctx.client.create_session( + {"model": "claude-sonnet-4.5", "on_permission_request": PermissionHandler.approve_all} + ) result = await session.rpc.model.get_current() assert result.model_id is not None @@ -89,7 +91,9 @@ async def test_should_call_session_rpc_model_switch_to(self, ctx: E2ETestContext """Test calling session.rpc.model.switchTo""" from copilot.generated.rpc import SessionModelSwitchToParams - session = await ctx.client.create_session({"model": "claude-sonnet-4.5"}) + session = await ctx.client.create_session( + {"model": "claude-sonnet-4.5", "on_permission_request": PermissionHandler.approve_all} + ) # Get initial model before = await session.rpc.model.get_current() @@ -112,7 +116,9 @@ async def test_get_and_set_session_mode(self): try: await client.start() - session = await client.create_session({}) + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) # Get initial mode (default should be interactive) initial = await session.rpc.mode.get() @@ -132,7 +138,7 @@ async def test_get_and_set_session_mode(self): ) assert interactive_result.mode == Mode.INTERACTIVE - await session.destroy() + await session.disconnect() await client.stop() finally: await client.force_stop() @@ -146,7 +152,9 @@ async def test_read_update_and_delete_plan(self): try: await client.start() - session = await client.create_session({}) + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) # Initially plan should not exist initial = await session.rpc.plan.read() @@ -170,7 +178,7 @@ async def test_read_update_and_delete_plan(self): assert after_delete.exists is False assert after_delete.content is None - await session.destroy() + await session.disconnect() await client.stop() finally: await client.force_stop() @@ -187,7 +195,9 @@ async def test_create_list_and_read_workspace_files(self): try: await client.start() - session = await client.create_session({}) + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) # Initially no files initial_files = await session.rpc.workspace.list_files() @@ -218,7 +228,7 @@ async def test_create_list_and_read_workspace_files(self): assert "test.txt" in after_nested.files assert any("nested.txt" in f for f in after_nested.files) - await session.destroy() + await session.disconnect() await client.stop() finally: await client.force_stop() diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index 0998298f4..60cb7c875 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -5,7 +5,7 @@ import pytest from copilot import CopilotClient, PermissionHandler -from copilot.types import Tool +from copilot.types import Tool, ToolResult from .testharness import E2ETestContext, get_final_assistant_message, get_next_event_of_type @@ -13,8 +13,10 @@ class TestSessions: - async def test_should_create_and_destroy_sessions(self, ctx: E2ETestContext): - session = await ctx.client.create_session({"model": "fake-test-model"}) + async def test_should_create_and_disconnect_sessions(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + {"model": "fake-test-model", "on_permission_request": PermissionHandler.approve_all} + ) assert session.session_id messages = await session.get_messages() @@ -23,13 +25,15 @@ async def test_should_create_and_destroy_sessions(self, ctx: E2ETestContext): assert messages[0].data.session_id == session.session_id assert messages[0].data.selected_model == "fake-test-model" - await session.destroy() + await session.disconnect() with pytest.raises(Exception, match="Session not found"): await session.get_messages() async def test_should_have_stateful_conversation(self, ctx: E2ETestContext): - session = await ctx.client.create_session() + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) assistant_message = await session.send_and_wait({"prompt": "What is 1+1?"}) assert assistant_message is not None @@ -46,7 +50,10 @@ async def test_should_create_a_session_with_appended_systemMessage_config( ): system_message_suffix = "End each response with the phrase 'Have a nice day!'" session = await ctx.client.create_session( - {"system_message": {"mode": "append", "content": system_message_suffix}} + { + "system_message": {"mode": "append", "content": system_message_suffix}, + "on_permission_request": PermissionHandler.approve_all, + } ) await session.send({"prompt": "What is your full name?"}) @@ -65,7 +72,10 @@ async def test_should_create_a_session_with_replaced_systemMessage_config( ): test_system_message = "You are an assistant called Testy McTestface. Reply succinctly." session = await ctx.client.create_session( - {"system_message": {"mode": "replace", "content": test_system_message}} + { + "system_message": {"mode": "replace", "content": test_system_message}, + "on_permission_request": PermissionHandler.approve_all, + } ) await session.send({"prompt": "What is your full name?"}) @@ -79,7 +89,12 @@ async def test_should_create_a_session_with_replaced_systemMessage_config( assert system_message == test_system_message # Exact match async def test_should_create_a_session_with_availableTools(self, ctx: E2ETestContext): - session = await ctx.client.create_session({"available_tools": ["view", "edit"]}) + session = await ctx.client.create_session( + { + "available_tools": ["view", "edit"], + "on_permission_request": PermissionHandler.approve_all, + } + ) await session.send({"prompt": "What is 1+1?"}) await get_final_assistant_message(session) @@ -93,7 +108,9 @@ async def test_should_create_a_session_with_availableTools(self, ctx: E2ETestCon assert "edit" in tool_names async def test_should_create_a_session_with_excludedTools(self, ctx: E2ETestContext): - session = await ctx.client.create_session({"excluded_tools": ["view"]}) + session = await ctx.client.create_session( + {"excluded_tools": ["view"], "on_permission_request": PermissionHandler.approve_all} + ) await session.send({"prompt": "What is 1+1?"}) await get_final_assistant_message(session) @@ -115,9 +132,9 @@ async def test_should_handle_multiple_concurrent_sessions(self, ctx: E2ETestCont import asyncio s1, s2, s3 = await asyncio.gather( - ctx.client.create_session(), - ctx.client.create_session(), - ctx.client.create_session(), + ctx.client.create_session({"on_permission_request": PermissionHandler.approve_all}), + ctx.client.create_session({"on_permission_request": PermissionHandler.approve_all}), + ctx.client.create_session({"on_permission_request": PermissionHandler.approve_all}), ) # All sessions should have unique IDs @@ -131,36 +148,51 @@ async def test_should_handle_multiple_concurrent_sessions(self, ctx: E2ETestCont assert messages[0].type.value == "session.start" assert messages[0].data.session_id == s.session_id - # All can be destroyed - await asyncio.gather(s1.destroy(), s2.destroy(), s3.destroy()) + # All can be disconnected + await asyncio.gather(s1.disconnect(), s2.disconnect(), s3.disconnect()) for s in [s1, s2, s3]: with pytest.raises(Exception, match="Session not found"): await s.get_messages() async def test_should_resume_a_session_using_the_same_client(self, ctx: E2ETestContext): # Create initial session - session1 = await ctx.client.create_session() + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) session_id = session1.session_id answer = await session1.send_and_wait({"prompt": "What is 1+1?"}) assert answer is not None assert "2" in answer.data.content # Resume using the same client - session2 = await ctx.client.resume_session(session_id) + session2 = await ctx.client.resume_session( + session_id, {"on_permission_request": PermissionHandler.approve_all} + ) assert session2.session_id == session_id answer2 = await get_final_assistant_message(session2) assert "2" in answer2.data.content + # Can continue the conversation statefully + answer3 = await session2.send_and_wait( + {"prompt": "Now if you double that, what do you get?"} + ) + assert answer3 is not None + assert "4" in answer3.data.content + async def test_should_resume_a_session_using_a_new_client(self, ctx: E2ETestContext): # Create initial session - session1 = await ctx.client.create_session() + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) session_id = session1.session_id answer = await session1.send_and_wait({"prompt": "What is 1+1?"}) assert answer is not None assert "2" in answer.data.content # Resume using a new client - github_token = "fake-token-for-e2e-tests" if os.environ.get("CI") == "true" else None + github_token = ( + "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None + ) new_client = CopilotClient( { "cli_path": ctx.cli_path, @@ -171,30 +203,42 @@ async def test_should_resume_a_session_using_a_new_client(self, ctx: E2ETestCont ) try: - session2 = await new_client.resume_session(session_id) + session2 = await new_client.resume_session( + session_id, {"on_permission_request": PermissionHandler.approve_all} + ) assert session2.session_id == session_id - # TODO: There's an inconsistency here. When resuming with a new client, - # we don't see the session.idle message in the history, which means we - # can't use get_final_assistant_message. messages = await session2.get_messages() message_types = [m.type.value for m in messages] assert "user.message" in message_types assert "session.resume" in message_types + + # Can continue the conversation statefully + answer2 = await session2.send_and_wait( + {"prompt": "Now if you double that, what do you get?"} + ) + assert answer2 is not None + assert "4" in answer2.data.content finally: await new_client.force_stop() async def test_should_throw_error_resuming_nonexistent_session(self, ctx: E2ETestContext): with pytest.raises(Exception): - await ctx.client.resume_session("non-existent-session-id") + await ctx.client.resume_session( + "non-existent-session-id", {"on_permission_request": PermissionHandler.approve_all} + ) async def test_should_list_sessions(self, ctx: E2ETestContext): import asyncio # Create a couple of sessions and send messages to persist them - session1 = await ctx.client.create_session() + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) await session1.send_and_wait({"prompt": "Say hello"}) - session2 = await ctx.client.create_session() + session2 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) await session2.send_and_wait({"prompt": "Say goodbye"}) # Small delay to ensure session files are written to disk @@ -231,7 +275,9 @@ async def test_should_delete_session(self, ctx: E2ETestContext): import asyncio # Create a session and send a message to persist it - session = await ctx.client.create_session() + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) await session.send_and_wait({"prompt": "Hello"}) session_id = session.session_id @@ -253,16 +299,35 @@ async def test_should_delete_session(self, ctx: E2ETestContext): # Verify we cannot resume the deleted session with pytest.raises(Exception): - await ctx.client.resume_session(session_id) + await ctx.client.resume_session( + session_id, {"on_permission_request": PermissionHandler.approve_all} + ) + + async def test_should_get_last_session_id(self, ctx: E2ETestContext): + import asyncio + + # Create a session and send a message to persist it + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + await session.send_and_wait({"prompt": "Say hello"}) + + # Small delay to ensure session data is flushed to disk + await asyncio.sleep(0.5) + + last_session_id = await ctx.client.get_last_session_id() + assert last_session_id == session.session_id + + await session.disconnect() async def test_should_create_session_with_custom_tool(self, ctx: E2ETestContext): # This test uses the low-level Tool() API to show that Pydantic is optional def get_secret_number_handler(invocation): - key = invocation["arguments"].get("key", "") - return { - "textResultForLlm": "54321" if key == "ALPHA" else "unknown", - "resultType": "success", - } + key = invocation.arguments.get("key", "") if invocation.arguments else "" + return ToolResult( + text_result_for_llm="54321" if key == "ALPHA" else "unknown", + result_type="success", + ) session = await ctx.client.create_session( { @@ -277,7 +342,8 @@ def get_secret_number_handler(invocation): "required": ["key"], }, ) - ] + ], + "on_permission_request": PermissionHandler.approve_all, } ) @@ -292,7 +358,8 @@ async def test_should_create_session_with_custom_provider(self, ctx: E2ETestCont "type": "openai", "base_url": "https://api.openai.com/v1", "api_key": "fake-key", - } + }, + "on_permission_request": PermissionHandler.approve_all, } ) assert session.session_id @@ -307,13 +374,16 @@ async def test_should_create_session_with_azure_provider(self, ctx: E2ETestConte "azure": { "api_version": "2024-02-15-preview", }, - } + }, + "on_permission_request": PermissionHandler.approve_all, } ) assert session.session_id async def test_should_resume_session_with_custom_provider(self, ctx: E2ETestContext): - session = await ctx.client.create_session() + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) session_id = session.session_id # Resume the session with a provider @@ -324,7 +394,8 @@ async def test_should_resume_session_with_custom_provider(self, ctx: E2ETestCont "type": "openai", "base_url": "https://api.openai.com/v1", "api_key": "fake-key", - } + }, + "on_permission_request": PermissionHandler.approve_all, }, ) @@ -376,65 +447,12 @@ async def test_should_abort_a_session(self, ctx: E2ETestContext): answer = await session.send_and_wait({"prompt": "What is 2+2?"}) assert "4" in answer.data.content - async def test_should_receive_streaming_delta_events_when_streaming_is_enabled( - self, ctx: E2ETestContext - ): - import asyncio - - session = await ctx.client.create_session({"streaming": True}) - - delta_contents = [] - done_event = asyncio.Event() - - def on_event(event): - if event.type.value == "assistant.message_delta": - delta = getattr(event.data, "delta_content", None) - if delta: - delta_contents.append(delta) - elif event.type.value == "session.idle": - done_event.set() - - session.on(on_event) - - await session.send({"prompt": "What is 2+2?"}) - - # Wait for completion - try: - await asyncio.wait_for(done_event.wait(), timeout=60) - except asyncio.TimeoutError: - pytest.fail("Timed out waiting for session.idle") - - # Should have received delta events - assert len(delta_contents) > 0, "Expected to receive delta events" - - # Get the final message to compare - assistant_message = await get_final_assistant_message(session) - - # Accumulated deltas should equal the final message - accumulated = "".join(delta_contents) - assert accumulated == assistant_message.data.content, ( - f"Accumulated deltas don't match final message.\n" - f"Accumulated: {accumulated!r}\nFinal: {assistant_message.data.content!r}" - ) - - # Final message should contain the answer - assert "4" in assistant_message.data.content - - async def test_should_pass_streaming_option_to_session_creation(self, ctx: E2ETestContext): - # Verify that the streaming option is accepted without errors - session = await ctx.client.create_session({"streaming": True}) - - assert session.session_id - - # Session should still work normally - await session.send({"prompt": "What is 1+1?"}) - assistant_message = await get_final_assistant_message(session) - assert "2" in assistant_message.data.content - async def test_should_receive_session_events(self, ctx: E2ETestContext): import asyncio - session = await ctx.client.create_session() + session = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) received_events = [] idle_event = asyncio.Event() @@ -451,7 +469,7 @@ def on_event(event): # Wait for session to become idle try: await asyncio.wait_for(idle_event.wait(), timeout=60) - except asyncio.TimeoutError: + except TimeoutError: pytest.fail("Timed out waiting for session.idle") # Should have received multiple events @@ -469,7 +487,12 @@ async def test_should_create_session_with_custom_config_dir(self, ctx: E2ETestCo import os custom_config_dir = os.path.join(ctx.home_dir, "custom-config") - session = await ctx.client.create_session({"config_dir": custom_config_dir}) + session = await ctx.client.create_session( + { + "config_dir": custom_config_dir, + "on_permission_request": PermissionHandler.approve_all, + } + ) assert session.session_id diff --git a/python/e2e/test_skills.py b/python/e2e/test_skills.py index 7f05140eb..166840e57 100644 --- a/python/e2e/test_skills.py +++ b/python/e2e/test_skills.py @@ -7,6 +7,8 @@ import pytest +from copilot import PermissionHandler + from .testharness import E2ETestContext pytestmark = pytest.mark.asyncio(loop_scope="module") @@ -53,7 +55,12 @@ class TestSkillBehavior: async def test_should_load_and_apply_skill_from_skilldirectories(self, ctx: E2ETestContext): """Test that skills are loaded and applied from skillDirectories""" skills_dir = create_skill_dir(ctx.work_dir) - session = await ctx.client.create_session({"skill_directories": [skills_dir]}) + session = await ctx.client.create_session( + { + "skill_directories": [skills_dir], + "on_permission_request": PermissionHandler.approve_all, + } + ) assert session.session_id is not None @@ -62,7 +69,7 @@ async def test_should_load_and_apply_skill_from_skilldirectories(self, ctx: E2ET assert message is not None assert SKILL_MARKER in message.data.content - await session.destroy() + await session.disconnect() async def test_should_not_apply_skill_when_disabled_via_disabledskills( self, ctx: E2ETestContext @@ -70,7 +77,11 @@ async def test_should_not_apply_skill_when_disabled_via_disabledskills( """Test that disabledSkills prevents skill from being applied""" skills_dir = create_skill_dir(ctx.work_dir) session = await ctx.client.create_session( - {"skill_directories": [skills_dir], "disabled_skills": ["test-skill"]} + { + "skill_directories": [skills_dir], + "disabled_skills": ["test-skill"], + "on_permission_request": PermissionHandler.approve_all, + } ) assert session.session_id is not None @@ -80,7 +91,7 @@ async def test_should_not_apply_skill_when_disabled_via_disabledskills( assert message is not None assert SKILL_MARKER not in message.data.content - await session.destroy() + await session.disconnect() @pytest.mark.skip( reason="See the big comment around the equivalent test in the Node SDK. " @@ -93,7 +104,9 @@ async def test_should_apply_skill_on_session_resume_with_skilldirectories( skills_dir = create_skill_dir(ctx.work_dir) # Create a session without skills first - session1 = await ctx.client.create_session() + session1 = await ctx.client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) session_id = session1.session_id # First message without skill - marker should not appear @@ -102,7 +115,13 @@ async def test_should_apply_skill_on_session_resume_with_skilldirectories( assert SKILL_MARKER not in message1.data.content # Resume with skillDirectories - skill should now be active - session2 = await ctx.client.resume_session(session_id, {"skill_directories": [skills_dir]}) + session2 = await ctx.client.resume_session( + session_id, + { + "skill_directories": [skills_dir], + "on_permission_request": PermissionHandler.approve_all, + }, + ) assert session2.session_id == session_id @@ -111,4 +130,4 @@ async def test_should_apply_skill_on_session_resume_with_skilldirectories( assert message2 is not None assert SKILL_MARKER in message2.data.content - await session2.destroy() + await session2.disconnect() diff --git a/python/e2e/test_streaming_fidelity.py b/python/e2e/test_streaming_fidelity.py new file mode 100644 index 000000000..d347015a0 --- /dev/null +++ b/python/e2e/test_streaming_fidelity.py @@ -0,0 +1,114 @@ +"""E2E Streaming Fidelity Tests""" + +import os + +import pytest + +from copilot import CopilotClient, PermissionHandler + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestStreamingFidelity: + async def test_should_produce_delta_events_when_streaming_is_enabled(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + {"streaming": True, "on_permission_request": PermissionHandler.approve_all} + ) + + events = [] + session.on(lambda event: events.append(event)) + + await session.send_and_wait({"prompt": "Count from 1 to 5, separated by commas."}) + + types = [e.type.value for e in events] + + # Should have streaming deltas before the final message + delta_events = [e for e in events if e.type.value == "assistant.message_delta"] + assert len(delta_events) >= 1 + + # Deltas should have content + for delta in delta_events: + delta_content = getattr(delta.data, "delta_content", None) + assert delta_content is not None + assert isinstance(delta_content, str) + + # Should still have a final assistant.message + assert "assistant.message" in types + + # Deltas should come before the final message + first_delta_idx = types.index("assistant.message_delta") + last_assistant_idx = len(types) - 1 - types[::-1].index("assistant.message") + assert first_delta_idx < last_assistant_idx + + await session.disconnect() + + async def test_should_not_produce_deltas_when_streaming_is_disabled(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + {"streaming": False, "on_permission_request": PermissionHandler.approve_all} + ) + + events = [] + session.on(lambda event: events.append(event)) + + await session.send_and_wait({"prompt": "Say 'hello world'."}) + + delta_events = [e for e in events if e.type.value == "assistant.message_delta"] + + # No deltas when streaming is off + assert len(delta_events) == 0 + + # But should still have a final assistant.message + assistant_events = [e for e in events if e.type.value == "assistant.message"] + assert len(assistant_events) >= 1 + + await session.disconnect() + + async def test_should_produce_deltas_after_session_resume(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + {"streaming": False, "on_permission_request": PermissionHandler.approve_all} + ) + await session.send_and_wait({"prompt": "What is 3 + 6?"}) + await session.disconnect() + + # Resume using a new client + github_token = ( + "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None + ) + new_client = CopilotClient( + { + "cli_path": ctx.cli_path, + "cwd": ctx.work_dir, + "env": ctx.get_env(), + "github_token": github_token, + } + ) + + try: + session2 = await new_client.resume_session( + session.session_id, + {"streaming": True, "on_permission_request": PermissionHandler.approve_all}, + ) + events = [] + session2.on(lambda event: events.append(event)) + + answer = await session2.send_and_wait( + {"prompt": "Now if you double that, what do you get?"} + ) + assert answer is not None + assert "18" in answer.data.content + + # Should have streaming deltas before the final message + delta_events = [e for e in events if e.type.value == "assistant.message_delta"] + assert len(delta_events) >= 1 + + # Deltas should have content + for delta in delta_events: + delta_content = getattr(delta.data, "delta_content", None) + assert delta_content is not None + assert isinstance(delta_content, str) + + await session2.disconnect() + finally: + await new_client.force_stop() diff --git a/python/e2e/test_tools.py b/python/e2e/test_tools.py index 10e61cf15..b692e3f65 100644 --- a/python/e2e/test_tools.py +++ b/python/e2e/test_tools.py @@ -5,7 +5,12 @@ import pytest from pydantic import BaseModel, Field -from copilot import PermissionHandler, ToolInvocation, define_tool +from copilot import ( + PermissionHandler, + PermissionRequestResult, + ToolInvocation, + define_tool, +) from .testharness import E2ETestContext, get_final_assistant_message @@ -34,7 +39,9 @@ class EncryptParams(BaseModel): def encrypt_string(params: EncryptParams, invocation: ToolInvocation) -> str: return params.input.upper() - session = await ctx.client.create_session({"tools": [encrypt_string]}) + session = await ctx.client.create_session( + {"tools": [encrypt_string], "on_permission_request": PermissionHandler.approve_all} + ) await session.send({"prompt": "Use encrypt_string to encrypt this string: Hello"}) assistant_message = await get_final_assistant_message(session) @@ -45,7 +52,9 @@ async def test_handles_tool_calling_errors(self, ctx: E2ETestContext): def get_user_location() -> str: raise Exception("Melbourne") - session = await ctx.client.create_session({"tools": [get_user_location]}) + session = await ctx.client.create_session( + {"tools": [get_user_location], "on_permission_request": PermissionHandler.approve_all} + ) await session.send( {"prompt": "What is my location? If you can't find out, just say 'unknown'."} @@ -101,14 +110,16 @@ def db_query(params: DbQueryParams, invocation: ToolInvocation) -> list[City]: assert params.query.table == "cities" assert params.query.ids == [12, 19] assert params.query.sortAscending is True - assert invocation["session_id"] == expected_session_id + assert invocation.session_id == expected_session_id return [ City(countryId=19, cityName="Passos", population=135460), City(countryId=12, cityName="San Lorenzo", population=204356), ] - session = await ctx.client.create_session({"tools": [db_query]}) + session = await ctx.client.create_session( + {"tools": [db_query], "on_permission_request": PermissionHandler.approve_all} + ) expected_session_id = session.session_id await session.send( @@ -126,3 +137,81 @@ def db_query(params: DbQueryParams, invocation: ToolInvocation) -> list[City]: assert "San Lorenzo" in response_content assert "135460" in response_content.replace(",", "") assert "204356" in response_content.replace(",", "") + + async def test_overrides_built_in_tool_with_custom_tool(self, ctx: E2ETestContext): + class GrepParams(BaseModel): + query: str = Field(description="Search query") + + @define_tool( + "grep", + description="A custom grep implementation that overrides the built-in", + overrides_built_in_tool=True, + ) + def custom_grep(params: GrepParams, invocation: ToolInvocation) -> str: + return f"CUSTOM_GREP_RESULT: {params.query}" + + session = await ctx.client.create_session( + {"tools": [custom_grep], "on_permission_request": PermissionHandler.approve_all} + ) + + await session.send({"prompt": "Use grep to search for the word 'hello'"}) + assistant_message = await get_final_assistant_message(session) + assert "CUSTOM_GREP_RESULT" in assistant_message.data.content + + async def test_invokes_custom_tool_with_permission_handler(self, ctx: E2ETestContext): + class EncryptParams(BaseModel): + input: str = Field(description="String to encrypt") + + @define_tool("encrypt_string", description="Encrypts a string") + def encrypt_string(params: EncryptParams, invocation: ToolInvocation) -> str: + return params.input.upper() + + permission_requests = [] + + def on_permission_request(request, invocation): + permission_requests.append(request) + return PermissionRequestResult(kind="approved") + + session = await ctx.client.create_session( + { + "tools": [encrypt_string], + "on_permission_request": on_permission_request, + } + ) + + await session.send({"prompt": "Use encrypt_string to encrypt this string: Hello"}) + assistant_message = await get_final_assistant_message(session) + assert "HELLO" in assistant_message.data.content + + # Should have received a custom-tool permission request + custom_tool_requests = [r for r in permission_requests if r.kind.value == "custom-tool"] + assert len(custom_tool_requests) > 0 + assert custom_tool_requests[0].tool_name == "encrypt_string" + + async def test_denies_custom_tool_when_permission_denied(self, ctx: E2ETestContext): + tool_handler_called = False + + class EncryptParams(BaseModel): + input: str = Field(description="String to encrypt") + + @define_tool("encrypt_string", description="Encrypts a string") + def encrypt_string(params: EncryptParams, invocation: ToolInvocation) -> str: + nonlocal tool_handler_called + tool_handler_called = True + return params.input.upper() + + def on_permission_request(request, invocation): + return PermissionRequestResult(kind="denied-interactively-by-user") + + session = await ctx.client.create_session( + { + "tools": [encrypt_string], + "on_permission_request": on_permission_request, + } + ) + + await session.send({"prompt": "Use encrypt_string to encrypt this string: Hello"}) + await get_final_assistant_message(session) + + # The tool handler should NOT have been called since permission was denied + assert not tool_handler_called diff --git a/python/e2e/test_tools_unit.py b/python/e2e/test_tools_unit.py index 7481c986f..c1a9163e1 100644 --- a/python/e2e/test_tools_unit.py +++ b/python/e2e/test_tools_unit.py @@ -5,7 +5,7 @@ import pytest from pydantic import BaseModel, Field -from copilot import ToolInvocation, define_tool +from copilot import ToolInvocation, ToolResult, define_tool from copilot.tools import _normalize_result @@ -62,12 +62,12 @@ def test_tool(params: Params, invocation: ToolInvocation) -> str: received_params = params return "ok" - invocation: ToolInvocation = { - "session_id": "session-1", - "tool_call_id": "call-1", - "tool_name": "test", - "arguments": {"name": "Alice", "count": 42}, - } + invocation = ToolInvocation( + session_id="session-1", + tool_call_id="call-1", + tool_name="test", + arguments={"name": "Alice", "count": 42}, + ) await test_tool.handler(invocation) @@ -87,17 +87,17 @@ def test_tool(params: Params, invocation: ToolInvocation) -> str: received_inv = invocation return "ok" - invocation: ToolInvocation = { - "session_id": "session-123", - "tool_call_id": "call-456", - "tool_name": "test", - "arguments": {}, - } + invocation = ToolInvocation( + session_id="session-123", + tool_call_id="call-456", + tool_name="test", + arguments={}, + ) await test_tool.handler(invocation) - assert received_inv["session_id"] == "session-123" - assert received_inv["tool_call_id"] == "call-456" + assert received_inv.session_id == "session-123" + assert received_inv.tool_call_id == "call-456" async def test_zero_param_handler(self): """Handler with no parameters: def handler() -> str""" @@ -109,17 +109,17 @@ def test_tool() -> str: called = True return "ok" - invocation: ToolInvocation = { - "session_id": "s1", - "tool_call_id": "c1", - "tool_name": "test", - "arguments": {}, - } + invocation = ToolInvocation( + session_id="s1", + tool_call_id="c1", + tool_name="test", + arguments={}, + ) result = await test_tool.handler(invocation) assert called - assert result["textResultForLlm"] == "ok" + assert result.text_result_for_llm == "ok" async def test_invocation_only_handler(self): """Handler with only invocation: def handler(invocation) -> str""" @@ -131,17 +131,17 @@ def test_tool(invocation: ToolInvocation) -> str: received_inv = invocation return "ok" - invocation: ToolInvocation = { - "session_id": "s1", - "tool_call_id": "c1", - "tool_name": "test", - "arguments": {}, - } + invocation = ToolInvocation( + session_id="s1", + tool_call_id="c1", + tool_name="test", + arguments={}, + ) await test_tool.handler(invocation) assert received_inv is not None - assert received_inv["session_id"] == "s1" + assert received_inv.session_id == "s1" async def test_params_only_handler(self): """Handler with only params: def handler(params) -> str""" @@ -157,12 +157,12 @@ def test_tool(params: Params) -> str: received_params = params return "ok" - invocation: ToolInvocation = { - "session_id": "s1", - "tool_call_id": "c1", - "tool_name": "test", - "arguments": {"value": "hello"}, - } + invocation = ToolInvocation( + session_id="s1", + tool_call_id="c1", + tool_name="test", + arguments={"value": "hello"}, + ) await test_tool.handler(invocation) @@ -177,20 +177,20 @@ class Params(BaseModel): def failing_tool(params: Params, invocation: ToolInvocation) -> str: raise ValueError("secret error message") - invocation: ToolInvocation = { - "session_id": "s1", - "tool_call_id": "c1", - "tool_name": "failing", - "arguments": {}, - } + invocation = ToolInvocation( + session_id="s1", + tool_call_id="c1", + tool_name="failing", + arguments={}, + ) result = await failing_tool.handler(invocation) - assert result["resultType"] == "failure" - assert "secret error message" not in result["textResultForLlm"] - assert "error" in result["textResultForLlm"].lower() + assert result.result_type == "failure" + assert "secret error message" not in result.text_result_for_llm + assert "error" in result.text_result_for_llm.lower() # But the actual error is stored internally - assert result["error"] == "secret error message" + assert result.error == "secret error message" async def test_function_style_api(self): class Params(BaseModel): @@ -207,14 +207,14 @@ class Params(BaseModel): assert tool.description == "My tool" result = await tool.handler( - { - "session_id": "s", - "tool_call_id": "c", - "tool_name": "my_tool", - "arguments": {"value": "hello"}, - } + ToolInvocation( + session_id="s", + tool_call_id="c", + tool_name="my_tool", + arguments={"value": "hello"}, + ) ) - assert result["textResultForLlm"] == "HELLO" + assert result.text_result_for_llm == "HELLO" def test_function_style_requires_name(self): class Params(BaseModel): @@ -231,34 +231,34 @@ class Params(BaseModel): class TestNormalizeResult: def test_none_returns_empty_success(self): result = _normalize_result(None) - assert result["textResultForLlm"] == "" - assert result["resultType"] == "success" + assert result.text_result_for_llm == "" + assert result.result_type == "success" def test_string_passes_through(self): result = _normalize_result("hello world") - assert result["textResultForLlm"] == "hello world" - assert result["resultType"] == "success" - - def test_dict_with_result_type_passes_through(self): - input_result = { - "textResultForLlm": "custom", - "resultType": "failure", - "error": "some error", - } + assert result.text_result_for_llm == "hello world" + assert result.result_type == "success" + + def test_tool_result_passes_through(self): + input_result = ToolResult( + text_result_for_llm="custom", + result_type="failure", + error="some error", + ) result = _normalize_result(input_result) - assert result["textResultForLlm"] == "custom" - assert result["resultType"] == "failure" + assert result.text_result_for_llm == "custom" + assert result.result_type == "failure" def test_dict_is_json_serialized(self): result = _normalize_result({"key": "value", "num": 42}) - parsed = json.loads(result["textResultForLlm"]) + parsed = json.loads(result.text_result_for_llm) assert parsed == {"key": "value", "num": 42} - assert result["resultType"] == "success" + assert result.result_type == "success" def test_list_is_json_serialized(self): result = _normalize_result(["a", "b", "c"]) - assert result["textResultForLlm"] == '["a", "b", "c"]' - assert result["resultType"] == "success" + assert result.text_result_for_llm == '["a", "b", "c"]' + assert result.result_type == "success" def test_pydantic_model_is_serialized(self): class Response(BaseModel): @@ -266,7 +266,7 @@ class Response(BaseModel): count: int result = _normalize_result(Response(status="ok", count=5)) - parsed = json.loads(result["textResultForLlm"]) + parsed = json.loads(result.text_result_for_llm) assert parsed == {"status": "ok", "count": 5} def test_list_of_pydantic_models_is_serialized(self): @@ -276,9 +276,9 @@ class Item(BaseModel): items = [Item(name="a", value=1), Item(name="b", value=2)] result = _normalize_result(items) - parsed = json.loads(result["textResultForLlm"]) + parsed = json.loads(result.text_result_for_llm) assert parsed == [{"name": "a", "value": 1}, {"name": "b", "value": 2}] - assert result["resultType"] == "success" + assert result.result_type == "success" def test_raises_for_unserializable_value(self): # Functions cannot be JSON serialized diff --git a/python/e2e/testharness/context.py b/python/e2e/testharness/context.py index 4417f567d..c03088912 100644 --- a/python/e2e/testharness/context.py +++ b/python/e2e/testharness/context.py @@ -9,7 +9,6 @@ import shutil import tempfile from pathlib import Path -from typing import Optional from copilot import CopilotClient @@ -46,8 +45,8 @@ def __init__(self): self.home_dir: str = "" self.work_dir: str = "" self.proxy_url: str = "" - self._proxy: Optional[CapiProxy] = None - self._client: Optional[CopilotClient] = None + self._proxy: CapiProxy | None = None + self._client: CopilotClient | None = None async def setup(self): """Set up the test context with a shared client.""" @@ -61,7 +60,9 @@ async def setup(self): # Create the shared client (like Node.js/Go do) # Use fake token in CI to allow cached responses without real auth - github_token = "fake-token-for-e2e-tests" if os.environ.get("CI") == "true" else None + github_token = ( + "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None + ) self._client = CopilotClient( { "cli_path": self.cli_path, @@ -78,7 +79,10 @@ async def teardown(self, test_failed: bool = False): test_failed: If True, skip writing snapshots to avoid corruption. """ if self._client: - await self._client.stop() + try: + await self._client.stop() + except ExceptionGroup: + pass # stop() completes all cleanup before raising; safe to ignore in teardown self._client = None if self._proxy: diff --git a/python/e2e/testharness/proxy.py b/python/e2e/testharness/proxy.py index e26ec65c3..65dd8bda9 100644 --- a/python/e2e/testharness/proxy.py +++ b/python/e2e/testharness/proxy.py @@ -9,7 +9,7 @@ import platform import re import subprocess -from typing import Any, Optional +from typing import Any import httpx @@ -18,8 +18,8 @@ class CapiProxy: """Manages a replaying proxy server for E2E tests.""" def __init__(self): - self._process: Optional[subprocess.Popen] = None - self._proxy_url: Optional[str] = None + self._process: subprocess.Popen | None = None + self._proxy_url: str | None = None async def start(self) -> str: """Launch the proxy server and return its URL.""" @@ -107,6 +107,6 @@ async def get_exchanges(self) -> list[dict[str, Any]]: return resp.json() @property - def url(self) -> Optional[str]: + def url(self) -> str | None: """Return the proxy URL, or None if not started.""" return self._proxy_url diff --git a/python/pyproject.toml b/python/pyproject.toml index 6c4d3e723..741232e8a 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -7,7 +7,7 @@ name = "github-copilot-sdk" version = "0.1.0" description = "Python SDK for GitHub Copilot CLI" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.11" license = "MIT" # license-files is set by scripts/build-wheels.mjs for bundled CLI wheels authors = [ @@ -17,15 +17,14 @@ classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = [ "python-dateutil>=2.9.0.post0", "pydantic>=2.0", - "typing-extensions>=4.0.0", ] [project.urls] @@ -42,12 +41,15 @@ dev = [ "httpx>=0.24.0", ] -[tool.setuptools] -packages = ["copilot"] +# Use find with a glob so that the copilot.bin subpackage (created dynamically +# by scripts/build-wheels.mjs during publishing) is included in platform wheels. +[tool.setuptools.packages.find] +where = ["."] +include = ["copilot*"] [tool.ruff] line-length = 100 -target-version = "py39" +target-version = "py311" exclude = [ "generated", "copilot/generated", @@ -61,9 +63,6 @@ select = [ "I", # isort "UP", # pyupgrade ] -ignore = [ - "UP006", -] [tool.ruff.format] quote-style = "double" diff --git a/python/test_client.py b/python/test_client.py index 0bc99ea69..bcc249f30 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -6,30 +6,31 @@ import pytest -from copilot import CopilotClient +from copilot import CopilotClient, PermissionHandler, define_tool from e2e.testharness import CLI_PATH -class TestHandleToolCallRequest: +class TestPermissionHandlerRequired: @pytest.mark.asyncio - async def test_returns_failure_when_tool_not_registered(self): + async def test_create_session_raises_without_permission_handler(self): client = CopilotClient({"cli_path": CLI_PATH}) await client.start() - try: - session = await client.create_session() + with pytest.raises(ValueError, match="on_permission_request.*is required"): + await client.create_session({}) + finally: + await client.force_stop() - response = await client._handle_tool_call_request( - { - "sessionId": session.session_id, - "toolCallId": "123", - "toolName": "missing_tool", - "arguments": {}, - } + @pytest.mark.asyncio + async def test_resume_session_raises_without_permission_handler(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + try: + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} ) - - assert response["result"]["resultType"] == "failure" - assert response["result"]["error"] == "tool 'missing_tool' not supported" + with pytest.raises(ValueError, match="on_permission_request.*is required"): + await client.resume_session(session.session_id, {}) finally: await client.force_stop() @@ -149,6 +150,70 @@ def test_use_logged_in_user_with_cli_url_raises(self): ) +class TestOverridesBuiltInTool: + @pytest.mark.asyncio + async def test_overrides_built_in_tool_sent_in_tool_definition(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + + try: + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + + @define_tool(description="Custom grep", overrides_built_in_tool=True) + def grep(params) -> str: + return "ok" + + await client.create_session( + {"tools": [grep], "on_permission_request": PermissionHandler.approve_all} + ) + tool_defs = captured["session.create"]["tools"] + assert len(tool_defs) == 1 + assert tool_defs[0]["name"] == "grep" + assert tool_defs[0]["overridesBuiltInTool"] is True + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_resume_session_sends_overrides_built_in_tool(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + + try: + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + return await original_request(method, params) + + client._client.request = mock_request + + @define_tool(description="Custom grep", overrides_built_in_tool=True) + def grep(params) -> str: + return "ok" + + await client.resume_session( + session.session_id, + {"tools": [grep], "on_permission_request": PermissionHandler.approve_all}, + ) + tool_defs = captured["session.resume"]["tools"] + assert len(tool_defs) == 1 + assert tool_defs[0]["overridesBuiltInTool"] is True + finally: + await client.force_stop() + + class TestSessionConfigForwarding: @pytest.mark.asyncio async def test_create_session_forwards_client_name(self): @@ -164,7 +229,9 @@ async def mock_request(method, params): return await original_request(method, params) client._client.request = mock_request - await client.create_session({"client_name": "my-app"}) + await client.create_session( + {"client_name": "my-app", "on_permission_request": PermissionHandler.approve_all} + ) assert captured["session.create"]["clientName"] == "my-app" finally: await client.force_stop() @@ -175,17 +242,51 @@ async def test_resume_session_forwards_client_name(self): await client.start() try: - session = await client.create_session() + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) captured = {} original_request = client._client.request async def mock_request(method, params): captured[method] = params + if method == "session.resume": + # Return a fake response to avoid needing real auth + return {"sessionId": session.session_id} return await original_request(method, params) client._client.request = mock_request - await client.resume_session(session.session_id, {"client_name": "my-app"}) + await client.resume_session( + session.session_id, + {"client_name": "my-app", "on_permission_request": PermissionHandler.approve_all}, + ) assert captured["session.resume"]["clientName"] == "my-app" finally: await client.force_stop() + + @pytest.mark.asyncio + async def test_set_model_sends_correct_rpc(self): + client = CopilotClient({"cli_path": CLI_PATH}) + await client.start() + + try: + session = await client.create_session( + {"on_permission_request": PermissionHandler.approve_all} + ) + + captured = {} + original_request = client._client.request + + async def mock_request(method, params): + captured[method] = params + if method == "session.model.switchTo": + return {} + return await original_request(method, params) + + client._client.request = mock_request + await session.set_model("gpt-4.1") + assert captured["session.model.switchTo"]["sessionId"] == session.session_id + assert captured["session.model.switchTo"]["modelId"] == "gpt-4.1" + finally: + await client.force_stop() diff --git a/python/test_rpc_timeout.py b/python/test_rpc_timeout.py new file mode 100644 index 000000000..7fca7615b --- /dev/null +++ b/python/test_rpc_timeout.py @@ -0,0 +1,134 @@ +"""Tests for timeout parameter on generated RPC methods.""" + +from unittest.mock import AsyncMock + +import pytest + +from copilot.generated.rpc import ( + FleetApi, + Mode, + ModeApi, + PlanApi, + ServerModelsApi, + ServerToolsApi, + SessionFleetStartParams, + SessionModeSetParams, + ToolsListParams, +) + + +class TestRpcTimeout: + """Tests for timeout forwarding across all four codegen branches: + - session-scoped with params + - session-scoped without params + - server-scoped with params + - server-scoped without params + """ + + # ── session-scoped, with params ────────────────────────────────── + + @pytest.mark.asyncio + async def test_default_timeout_not_forwarded(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"started": True}) + api = FleetApi(client, "sess-1") + + await api.start(SessionFleetStartParams(prompt="go")) + + client.request.assert_called_once() + _, kwargs = client.request.call_args + assert "timeout" not in kwargs + + @pytest.mark.asyncio + async def test_custom_timeout_forwarded(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"started": True}) + api = FleetApi(client, "sess-1") + + await api.start(SessionFleetStartParams(prompt="go"), timeout=600.0) + + _, kwargs = client.request.call_args + assert kwargs["timeout"] == 600.0 + + @pytest.mark.asyncio + async def test_timeout_on_session_params_method(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"mode": "plan"}) + api = ModeApi(client, "sess-1") + + await api.set(SessionModeSetParams(mode=Mode.PLAN), timeout=120.0) + + _, kwargs = client.request.call_args + assert kwargs["timeout"] == 120.0 + + # ── session-scoped, no params ──────────────────────────────────── + + @pytest.mark.asyncio + async def test_timeout_on_session_no_params_method(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"exists": True}) + api = PlanApi(client, "sess-1") + + await api.read(timeout=90.0) + + _, kwargs = client.request.call_args + assert kwargs["timeout"] == 90.0 + + @pytest.mark.asyncio + async def test_default_timeout_on_session_no_params_method(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"exists": True}) + api = PlanApi(client, "sess-1") + + await api.read() + + _, kwargs = client.request.call_args + assert "timeout" not in kwargs + + # ── server-scoped, with params ───────────────────────────────────── + + @pytest.mark.asyncio + async def test_timeout_on_server_params_method(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"tools": []}) + api = ServerToolsApi(client) + + await api.list(ToolsListParams(), timeout=60.0) + + _, kwargs = client.request.call_args + assert kwargs["timeout"] == 60.0 + + @pytest.mark.asyncio + async def test_default_timeout_on_server_params_method(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"tools": []}) + api = ServerToolsApi(client) + + await api.list(ToolsListParams()) + + _, kwargs = client.request.call_args + assert "timeout" not in kwargs + + # ── server-scoped, no params ───────────────────────────────────── + + @pytest.mark.asyncio + async def test_timeout_on_server_no_params_method(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"models": []}) + api = ServerModelsApi(client) + + await api.list(timeout=45.0) + + _, kwargs = client.request.call_args + assert kwargs["timeout"] == 45.0 + + @pytest.mark.asyncio + async def test_default_timeout_on_server_no_params_method(self): + client = AsyncMock() + client.request = AsyncMock(return_value={"models": []}) + api = ServerModelsApi(client) + + await api.list() + + _, kwargs = client.request.call_args + assert "timeout" not in kwargs diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index e5e0fcf9a..463d856c8 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -393,6 +393,9 @@ function generateSessionEventsCode(schema: JSONSchema7): string { // AUTO-GENERATED FILE - DO NOT EDIT // Generated from: session-events.schema.json +// Generated code does not have XML doc comments; suppress CS1591 to avoid warnings. +#pragma warning disable CS1591 + using System.Text.Json; using System.Text.Json.Serialization; @@ -467,6 +470,14 @@ function singularPascal(s: string): string { } function resolveRpcType(schema: JSONSchema7, isRequired: boolean, parentClassName: string, propName: string, classes: string[]): string { + // Handle anyOf: [T, null] → T? (nullable typed property) + if (schema.anyOf) { + const hasNull = schema.anyOf.some((s) => typeof s === "object" && (s as JSONSchema7).type === "null"); + const nonNull = schema.anyOf.filter((s) => typeof s === "object" && (s as JSONSchema7).type !== "null"); + if (nonNull.length === 1) { + return resolveRpcType(nonNull[0] as JSONSchema7, isRequired && !hasNull, parentClassName, propName, classes); + } + } // Handle enums (string unions like "interactive" | "plan" | "autopilot") if (schema.enum && Array.isArray(schema.enum)) { const enumName = getOrCreateEnum(parentClassName, propName, schema.enum as string[], rpcEnumOutput); @@ -524,7 +535,9 @@ function emitRpcClass(className: string, schema: JSONSchema7, visibility: "publi let defaultVal = ""; if (isReq && !csharpType.endsWith("?")) { if (csharpType === "string") defaultVal = " = string.Empty;"; - else if (csharpType.startsWith("List<") || csharpType.startsWith("Dictionary<") || emittedRpcClasses.has(csharpType)) defaultVal = " = new();"; + else if (csharpType === "object") defaultVal = " = null!;"; + else if (csharpType.startsWith("List<") || csharpType.startsWith("Dictionary<")) defaultVal = " = [];"; + else if (emittedRpcClasses.has(csharpType)) defaultVal = " = new();"; } lines.push(` public ${csharpType} ${csharpName} { get; set; }${defaultVal}`); if (i < props.length - 1) lines.push(""); @@ -555,7 +568,7 @@ function emitServerRpcClasses(node: Record, classes: string[]): srLines.push(` {`); srLines.push(` _rpc = rpc;`); for (const [groupName] of groups) { - srLines.push(` ${toPascalCase(groupName)} = new ${toPascalCase(groupName)}Api(rpc);`); + srLines.push(` ${toPascalCase(groupName)} = new Server${toPascalCase(groupName)}Api(rpc);`); } srLines.push(` }`); @@ -569,7 +582,7 @@ function emitServerRpcClasses(node: Record, classes: string[]): for (const [groupName] of groups) { srLines.push(""); srLines.push(` /// ${toPascalCase(groupName)} APIs.`); - srLines.push(` public ${toPascalCase(groupName)}Api ${toPascalCase(groupName)} { get; }`); + srLines.push(` public Server${toPascalCase(groupName)}Api ${toPascalCase(groupName)} { get; }`); } srLines.push(`}`); @@ -577,7 +590,7 @@ function emitServerRpcClasses(node: Record, classes: string[]): // Per-group API classes for (const [groupName, groupNode] of groups) { - result.push(emitServerApiClass(`${toPascalCase(groupName)}Api`, groupNode as Record, classes)); + result.push(emitServerApiClass(`Server${toPascalCase(groupName)}Api`, groupNode as Record, classes)); } return result; @@ -585,7 +598,8 @@ function emitServerRpcClasses(node: Record, classes: string[]): function emitServerApiClass(className: string, node: Record, classes: string[]): string { const lines: string[] = []; - lines.push(`/// Server-scoped ${className.replace("Api", "")} APIs.`); + const displayName = className.replace(/^Server/, "").replace(/Api$/, ""); + lines.push(`/// Server-scoped ${displayName} APIs.`); lines.push(`public class ${className}`); lines.push(`{`); lines.push(` private readonly JsonRpc _rpc;`); @@ -621,7 +635,7 @@ function emitServerInstanceMethod( let requestClassName: string | null = null; if (paramEntries.length > 0) { - requestClassName = `${methodName}Request`; + requestClassName = `${typeToClassName(method.rpcMethod)}Request`; const reqClass = emitRpcClass(requestClassName, method.params!, "internal", classes); if (reqClass) classes.push(reqClass); } @@ -685,7 +699,7 @@ function emitSessionApiClass(className: string, node: Record, c const paramEntries = (method.params?.properties ? Object.entries(method.params.properties) : []).filter(([k]) => k !== "sessionId"); const requiredSet = new Set(method.params?.required || []); - const requestClassName = `${methodName}Request`; + const requestClassName = `${typeToClassName(method.rpcMethod)}Request`; if (method.params) { const reqClass = emitRpcClass(requestClassName, method.params, "internal", classes); if (reqClass) classes.push(reqClass); @@ -730,6 +744,9 @@ function generateRpcCode(schema: ApiSchema): string { // AUTO-GENERATED FILE - DO NOT EDIT // Generated from: api.schema.json +// Generated code does not have XML doc comments; suppress CS1591 to avoid warnings. +#pragma warning disable CS1591 + using System.Text.Json; using System.Text.Json.Serialization; using StreamJsonRpc; diff --git a/scripts/codegen/go.ts b/scripts/codegen/go.ts index 411d1c90f..1ebc50797 100644 --- a/scripts/codegen/go.ts +++ b/scripts/codegen/go.ts @@ -198,7 +198,8 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio // Emit API structs for groups for (const [groupName, groupNode] of groups) { - const apiName = toPascalCase(groupName) + apiSuffix; + const prefix = isSession ? "" : "Server"; + const apiName = prefix + toPascalCase(groupName) + apiSuffix; const fields = isSession ? "client *jsonrpc2.Client; sessionID string" : "client *jsonrpc2.Client"; lines.push(`type ${apiName} struct { ${fields} }`); lines.push(``); @@ -214,7 +215,8 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio lines.push(` client *jsonrpc2.Client`); if (isSession) lines.push(` sessionID string`); for (const [groupName] of groups) { - lines.push(` ${toPascalCase(groupName)} *${toPascalCase(groupName)}${apiSuffix}`); + const prefix = isSession ? "" : "Server"; + lines.push(` ${toPascalCase(groupName)} *${prefix}${toPascalCase(groupName)}${apiSuffix}`); } lines.push(`}`); lines.push(``); @@ -231,9 +233,10 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio lines.push(`func New${wrapperName}(${ctorParams}) *${wrapperName} {`); lines.push(` return &${wrapperName}{${ctorFields}`); for (const [groupName] of groups) { + const prefix = isSession ? "" : "Server"; const apiInit = isSession ? `&${toPascalCase(groupName)}${apiSuffix}{client: client, sessionID: sessionID}` - : `&${toPascalCase(groupName)}${apiSuffix}{client: client}`; + : `&${prefix}${toPascalCase(groupName)}${apiSuffix}{client: client}`; lines.push(` ${toPascalCase(groupName)}: ${apiInit},`); } lines.push(` }`); diff --git a/scripts/codegen/python.ts b/scripts/codegen/python.ts index aa688782b..65563d741 100644 --- a/scripts/codegen/python.ts +++ b/scripts/codegen/python.ts @@ -21,6 +21,54 @@ import { // ── Utilities ─────────────────────────────────────────────────────────────── +/** + * Modernize quicktype's Python 3.7 output to Python 3.11+ syntax: + * - Optional[T] → T | None + * - List[T] → list[T] + * - Dict[K, V] → dict[K, V] + * - Type[T] → type[T] + * - Callable from collections.abc instead of typing + * - Clean up unused typing imports + */ +function modernizePython(code: string): string { + // Replace Optional[X] with X | None (handles nested brackets) + code = code.replace(/Optional\[([^\[\]]*(?:\[[^\[\]]*\])*[^\[\]]*)\]/g, "$1 | None"); + + // Replace Union[X, Y] with X | Y + code = code.replace(/Union\[([^\[\]]*(?:\[[^\[\]]*\])*[^\[\]]*)\]/g, (_match, inner: string) => { + return inner.split(",").map((s: string) => s.trim()).join(" | "); + }); + + // Replace List[X] with list[X] + code = code.replace(/\bList\[/g, "list["); + + // Replace Dict[K, V] with dict[K, V] + code = code.replace(/\bDict\[/g, "dict["); + + // Replace Type[T] with type[T] + code = code.replace(/\bType\[/g, "type["); + + // Move Callable from typing to collections.abc + code = code.replace( + /from typing import (.*), Callable$/m, + "from typing import $1\nfrom collections.abc import Callable" + ); + code = code.replace( + /from typing import Callable, (.*)$/m, + "from typing import $1\nfrom collections.abc import Callable" + ); + + // Remove now-unused imports from typing (Optional, List, Dict, Type) + code = code.replace(/from typing import (.+)$/m, (_match, imports: string) => { + const items = imports.split(",").map((s: string) => s.trim()); + const remove = new Set(["Optional", "List", "Dict", "Type", "Union"]); + const kept = items.filter((i: string) => !remove.has(i)); + return `from typing import ${kept.join(", ")}`; + }); + + return code; +} + function toSnakeCase(s: string): string { return s .replace(/([a-z])([A-Z])/g, "$1_$2") @@ -75,6 +123,8 @@ async function generateSessionEvents(schemaPath?: string): Promise { code = code.replace(/: Any$/gm, ": Any = None"); // Fix bare except: to use Exception (required by ruff/pylint) code = code.replace(/except:/g, "except Exception:"); + // Modernize to Python 3.11+ syntax + code = modernizePython(code); // Add UNKNOWN enum value for forward compatibility code = code.replace( @@ -162,6 +212,8 @@ async function generateRpc(schemaPath?: string): Promise { typesCode = typesCode.replace(/except:/g, "except Exception:"); // Remove unnecessary pass when class has methods (quicktype generates pass for empty schemas) typesCode = typesCode.replace(/^(\s*)pass\n\n(\s*@staticmethod)/gm, "$2"); + // Modernize to Python 3.11+ syntax + typesCode = modernizePython(typesCode); const lines: string[] = []; lines.push(`""" @@ -176,7 +228,14 @@ if TYPE_CHECKING: `); lines.push(typesCode); - lines.push(``); + lines.push(` +def _timeout_kwargs(timeout: float | None) -> dict: + """Build keyword arguments for optional timeout forwarding.""" + if timeout is not None: + return {"timeout": timeout} + return {} + +`); // Emit RPC wrapper classes if (schema.server) { @@ -198,7 +257,8 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio // Emit API classes for groups for (const [groupName, groupNode] of groups) { - const apiName = toPascalCase(groupName) + "Api"; + const prefix = isSession ? "" : "Server"; + const apiName = prefix + toPascalCase(groupName) + "Api"; if (isSession) { lines.push(`class ${apiName}:`); lines.push(` def __init__(self, client: "JsonRpcClient", session_id: str):`); @@ -233,7 +293,7 @@ function emitRpcWrapper(lines: string[], node: Record, isSessio lines.push(` def __init__(self, client: "JsonRpcClient"):`); lines.push(` self._client = client`); for (const [groupName] of groups) { - lines.push(` self.${toSnakeCase(groupName)} = ${toPascalCase(groupName)}Api(client)`); + lines.push(` self.${toSnakeCase(groupName)} = Server${toPascalCase(groupName)}Api(client)`); } } lines.push(``); @@ -255,10 +315,10 @@ function emitMethod(lines: string[], name: string, method: RpcMethod, isSession: const hasParams = isSession ? nonSessionParams.length > 0 : Object.keys(paramProps).length > 0; const paramsType = toPascalCase(method.rpcMethod) + "Params"; - // Build signature with typed params + // Build signature with typed params + optional timeout const sig = hasParams - ? ` async def ${methodName}(self, params: ${paramsType}) -> ${resultType}:` - : ` async def ${methodName}(self) -> ${resultType}:`; + ? ` async def ${methodName}(self, params: ${paramsType}, *, timeout: float | None = None) -> ${resultType}:` + : ` async def ${methodName}(self, *, timeout: float | None = None) -> ${resultType}:`; lines.push(sig); @@ -267,16 +327,16 @@ function emitMethod(lines: string[], name: string, method: RpcMethod, isSession: if (hasParams) { lines.push(` params_dict = {k: v for k, v in params.to_dict().items() if v is not None}`); lines.push(` params_dict["sessionId"] = self._session_id`); - lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", params_dict))`); + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", params_dict, **_timeout_kwargs(timeout)))`); } else { - lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", {"sessionId": self._session_id}))`); + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", {"sessionId": self._session_id}, **_timeout_kwargs(timeout)))`); } } else { if (hasParams) { lines.push(` params_dict = {k: v for k, v in params.to_dict().items() if v is not None}`); - lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", params_dict))`); + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", params_dict, **_timeout_kwargs(timeout)))`); } else { - lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", {}))`); + lines.push(` return ${resultType}.from_dict(await self._client.request("${method.rpcMethod}", {}, **_timeout_kwargs(timeout)))`); } } lines.push(``); diff --git a/scripts/docs-validation/extract.ts b/scripts/docs-validation/extract.ts index 2fc48d82d..879873048 100644 --- a/scripts/docs-validation/extract.ts +++ b/scripts/docs-validation/extract.ts @@ -31,6 +31,7 @@ interface CodeBlock { file: string; line: number; skip: boolean; + hidden: boolean; wrapAsync: boolean; } @@ -58,6 +59,7 @@ function parseMarkdownCodeBlocks( let blockStartLine = 0; let skipNext = false; let wrapAsync = false; + let inHiddenBlock = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; @@ -71,6 +73,16 @@ function parseMarkdownCodeBlocks( wrapAsync = true; continue; } + if (line.includes("")) { + inHiddenBlock = true; + continue; + } + if (line.includes("")) { + inHiddenBlock = false; + // Skip the next visible code block since the hidden one replaces it + skipNext = true; + continue; + } // Start of code block if (!inCodeBlock && line.startsWith("```")) { @@ -92,12 +104,17 @@ function parseMarkdownCodeBlocks( file: filePath, line: blockStartLine, skip: skipNext, + hidden: inHiddenBlock, wrapAsync: wrapAsync, }); inCodeBlock = false; currentLang = ""; currentCode = []; - skipNext = false; + // Only reset skipNext when NOT in a hidden block — hidden blocks + // can contain multiple code fences that all get validated. + if (!inHiddenBlock) { + skipNext = false; + } wrapAsync = false; continue; } @@ -358,6 +375,7 @@ async function main() { const langCounts = new Map(); let totalBlocks = 0; let skippedBlocks = 0; + let hiddenBlocks = 0; for (const mdFile of mdFiles) { const fullPath = path.join(DOCS_DIR, mdFile); @@ -370,6 +388,10 @@ async function main() { continue; } + if (block.hidden) { + hiddenBlocks++; + } + // Skip empty or trivial blocks if (block.code.trim().length < 10) { continue; @@ -426,6 +448,9 @@ async function main() { if (skippedBlocks > 0) { console.log(` Skipped ${skippedBlocks}`); } + if (hiddenBlocks > 0) { + console.log(` Hidden ${hiddenBlocks}`); + } console.log(`\nOutput: ${OUTPUT_DIR}`); } diff --git a/scripts/docs-validation/package-lock.json b/scripts/docs-validation/package-lock.json index aa37d5df4..15f331453 100644 --- a/scripts/docs-validation/package-lock.json +++ b/scripts/docs-validation/package-lock.json @@ -429,27 +429,6 @@ "node": ">=18" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -491,6 +470,27 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -682,15 +682,15 @@ } }, "node_modules/minimatch": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", - "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" diff --git a/scripts/docs-validation/validate.ts b/scripts/docs-validation/validate.ts index 109430c81..c1d408c36 100644 --- a/scripts/docs-validation/validate.ts +++ b/scripts/docs-validation/validate.ts @@ -5,7 +5,7 @@ import * as fs from "fs"; import * as path from "path"; -import { execSync, spawn } from "child_process"; +import { execFileSync } from "child_process"; import { glob } from "glob"; const ROOT_DIR = path.resolve(import.meta.dirname, "../.."); @@ -76,7 +76,7 @@ async function validateTypeScript(): Promise { try { // Run tsc const tscPath = path.join(ROOT_DIR, "nodejs/node_modules/.bin/tsc"); - execSync(`${tscPath} --project ${tsconfigPath} 2>&1`, { + execFileSync(tscPath, ["--project", tsconfigPath], { encoding: "utf-8", cwd: tsDir, }); @@ -98,10 +98,8 @@ async function validateTypeScript(): Promise { } } catch (err: any) { // Parse tsc output for errors - const output = err.stdout || err.message || ""; + const output = err.stdout || err.stderr || err.message || ""; const errorLines = output.split("\n"); - - // Group errors by file const fileErrors = new Map(); let currentFile = ""; @@ -162,22 +160,23 @@ async function validatePython(): Promise { // Syntax check with py_compile try { - execSync(`python3 -m py_compile "${fullPath}" 2>&1`, { + execFileSync("python3", ["-m", "py_compile", fullPath], { encoding: "utf-8", }); } catch (err: any) { - errors.push(err.stdout || err.message || "Syntax error"); + errors.push(err.stdout || err.stderr || err.message || "Syntax error"); } // Type check with mypy (if available) if (errors.length === 0) { try { - execSync( - `python3 -m mypy "${fullPath}" --ignore-missing-imports --no-error-summary 2>&1`, + execFileSync( + "python3", + ["-m", "mypy", fullPath, "--ignore-missing-imports", "--no-error-summary"], { encoding: "utf-8" } ); } catch (err: any) { - const output = err.stdout || err.message || ""; + const output = err.stdout || err.stderr || err.message || ""; // Filter out "Success" messages and notes const typeErrors = output .split("\n") @@ -227,7 +226,7 @@ replace github.com/github/copilot-sdk/go => ${path.join(ROOT_DIR, "go")} // Run go mod tidy to fetch dependencies try { - execSync(`go mod tidy 2>&1`, { + execFileSync("go", ["mod", "tidy"], { encoding: "utf-8", cwd: goDir, env: { ...process.env, GO111MODULE: "on" }, @@ -246,7 +245,7 @@ replace github.com/github/copilot-sdk/go => ${path.join(ROOT_DIR, "go")} try { // Use go vet for syntax and basic checks - execSync(`go build -o /dev/null "${fullPath}" 2>&1`, { + execFileSync("go", ["build", "-o", "/dev/null", fullPath], { encoding: "utf-8", cwd: goDir, env: { ...process.env, GO111MODULE: "on" }, @@ -300,7 +299,7 @@ async function validateCSharp(): Promise { // Compile all files together try { - execSync(`dotnet build "${path.join(csDir, "DocsValidation.csproj")}" 2>&1`, { + execFileSync("dotnet", ["build", path.join(csDir, "DocsValidation.csproj")], { encoding: "utf-8", cwd: csDir, }); @@ -466,6 +465,15 @@ async function main() { console.log(" 3. Re-run: npm run validate"); console.log("\nTo skip a code block, add before it:"); console.log(" "); + console.log("\nTo validate a complete version while showing a snippet:"); + console.log(" "); + console.log(" ```lang"); + console.log(" // full compilable code"); + console.log(" ```"); + console.log(" "); + console.log(" ```lang"); + console.log(" // visible snippet (auto-skipped)"); + console.log(" ```"); process.exit(1); } diff --git a/sdk-protocol-version.json b/sdk-protocol-version.json index 4bb5680c7..cd2f236b2 100644 --- a/sdk-protocol-version.json +++ b/sdk-protocol-version.json @@ -1,3 +1,3 @@ { - "version": 2 + "version": 3 } diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index 0bb201f5f..38616186d 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -9,9 +9,9 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@github/copilot": "^0.0.411", + "@github/copilot": "^0.0.421", "@modelcontextprotocol/sdk": "^1.26.0", - "@types/node": "^25.2.0", + "@types/node": "^25.3.3", "openai": "^6.17.0", "tsx": "^4.21.0", "typescript": "^5.9.3", @@ -462,27 +462,27 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.411.tgz", - "integrity": "sha512-I3/7gw40Iu1O+kTyNPKJHNqDRyOebjsUW6wJsvSVrOpT0TNa3/lfm8xdS2XUuJWkp+PgEG/PRwF7u3DVNdP7bQ==", + "version": "0.0.421", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.421.tgz", + "integrity": "sha512-nDUt9f5al7IgBOTc7AwLpqvaX61VsRDYDQ9D5iR0QQzHo4pgDcyOXIjXUQUKsJwObXHfh6qR+Jm1vnlbw5cacg==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.411", - "@github/copilot-darwin-x64": "0.0.411", - "@github/copilot-linux-arm64": "0.0.411", - "@github/copilot-linux-x64": "0.0.411", - "@github/copilot-win32-arm64": "0.0.411", - "@github/copilot-win32-x64": "0.0.411" + "@github/copilot-darwin-arm64": "0.0.421", + "@github/copilot-darwin-x64": "0.0.421", + "@github/copilot-linux-arm64": "0.0.421", + "@github/copilot-linux-x64": "0.0.421", + "@github/copilot-win32-arm64": "0.0.421", + "@github/copilot-win32-x64": "0.0.421" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.411.tgz", - "integrity": "sha512-dtr+iHxTS4f8HlV2JT9Fp0FFoxuiPWCnU3XGmrHK+rY6bX5okPC2daU5idvs77WKUGcH8yHTZtfbKYUiMxKosw==", + "version": "0.0.421", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.421.tgz", + "integrity": "sha512-S4plFsxH7W8X1gEkGNcfyKykIji4mNv8BP/GpPs2Ad84qWoJpZzfZsjrjF0BQ8mvFObWp6Ft2SZOnJzFZW1Ftw==", "cpu": [ "arm64" ], @@ -497,9 +497,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.411.tgz", - "integrity": "sha512-zhdbQCbPi1L4iHClackSLx8POfklA+NX9RQLuS48HlKi/0KI/JlaDA/bdbIeMR79wjif5t9gnc/m+RTVmHlRtA==", + "version": "0.0.421", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.421.tgz", + "integrity": "sha512-h+Dbfq8ByAielLYIeJbjkN/9Abs6AKHFi+XuuzEy4YA9jOA42uKMFsWYwaoYH8ZLK9Y+4wagYI9UewVPnyIWPA==", "cpu": [ "x64" ], @@ -514,9 +514,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.411.tgz", - "integrity": "sha512-oZYZ7oX/7O+jzdTUcHkfD1A8YnNRW6mlUgdPjUg+5rXC43bwIdyatAnc0ObY21m9h8ghxGqholoLhm5WnGv1LQ==", + "version": "0.0.421", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.421.tgz", + "integrity": "sha512-cxlqDRR/wKfbdzd456N2h7sZOZY069wU2ycSYSmo7cC75U5DyhMGYAZwyAhvQ7UKmS5gJC/wgSgye0njuK22Xg==", "cpu": [ "arm64" ], @@ -531,9 +531,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.411.tgz", - "integrity": "sha512-nnXrKANmmGnkwa3ROlKdAhVNOx8daeMSE8Xh0o3ybKckFv4s38blhKdcxs0RJQRxgAk4p7XXGlDDKNRhurqF1g==", + "version": "0.0.421", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.421.tgz", + "integrity": "sha512-7np5b6EEemJ3U3jnl92buJ88nlpqOAIrLaJxx3pJGrP9SVFMBD/6EAlfIQ5m5QTfs+/vIuTKWBrq1wpFVZZUcQ==", "cpu": [ "x64" ], @@ -548,9 +548,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.411.tgz", - "integrity": "sha512-h+Bovb2YVCQSeELZOO7zxv8uht45XHcvAkFbRsc1gf9dl109sSUJIcB4KAhs8Aznk28qksxz7kvdSgUWyQBlIA==", + "version": "0.0.421", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.421.tgz", + "integrity": "sha512-T6qCqOnijD5pmC0ytVsahX3bpDnXtLTgo9xFGo/BGaPEvX02ePkzcRZkfkOclkzc8QlkVji6KqZYB+qMZTliwg==", "cpu": [ "arm64" ], @@ -565,9 +565,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.411.tgz", - "integrity": "sha512-xmOgi1lGvUBHQJWmq5AK1EP95+Y8xR4TFoK9OCSOaGbQ+LFcX2jF7iavnMolfWwddabew/AMQjsEHlXvbgMG8Q==", + "version": "0.0.421", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.421.tgz", + "integrity": "sha512-KDfy3wsRQFIcOQDdd5Mblvh+DWRq+UGbTQ34wyW36ws1BsdWkV++gk9bTkeJRsPbQ51wsJ0V/jRKEZv4uK5dTA==", "cpu": [ "x64" ], @@ -1025,13 +1025,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", - "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@vitest/expect": { @@ -1823,9 +1823,9 @@ } }, "node_modules/hono": { - "version": "4.11.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", - "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", + "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", "dev": true, "license": "MIT", "engines": { @@ -2660,9 +2660,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, diff --git a/test/harness/package.json b/test/harness/package.json index 65edf4cbc..6998dc74a 100644 --- a/test/harness/package.json +++ b/test/harness/package.json @@ -11,9 +11,9 @@ "test": "vitest run" }, "devDependencies": { - "@github/copilot": "^0.0.411", + "@github/copilot": "^0.0.421", "@modelcontextprotocol/sdk": "^1.26.0", - "@types/node": "^25.2.0", + "@types/node": "^25.3.3", "openai": "^6.17.0", "tsx": "^4.21.0", "typescript": "^5.9.3", diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index d3dab9dc2..7481bc2f7 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -2,7 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import type { retrieveAvailableModels } from "@github/copilot/sdk"; import { existsSync } from "fs"; import { mkdir, readFile, writeFile } from "fs/promises"; import type { @@ -281,11 +280,38 @@ export class ReplayingCapiProxy extends CapturingHttpProxy { return; } + + // Check if this request matches a snapshot with no response (e.g., timeout tests). + // If so, hang forever so the client-side timeout can trigger. + if ( + await isRequestOnlySnapshot( + state.storedData, + options.body, + state.workDir, + state.toolResultNormalizers, + ) + ) { + const streamingIsRequested = + options.body && + (JSON.parse(options.body) as { stream?: boolean }).stream === + true; + const headers = { + "content-type": streamingIsRequested + ? "text/event-stream" + : "application/json", + ...commonResponseHeaders, + }; + options.onResponseStart(200, headers); + // Never call onResponseEnd - hang indefinitely for timeout tests. + // Returning here keeps the HTTP response open without leaking a pending Promise. + return; + } } // Fallback to normal proxying if no cached response found // This implicitly captures the new exchange too - if (process.env.CI === "true") { + const isCI = process.env.GITHUB_ACTIONS === "true"; + if (isCI) { await exitWithNoMatchingRequestError( options, state.testInfo, @@ -395,6 +421,35 @@ async function findSavedChatCompletionResponse( return undefined; } +// Checks if the request matches a snapshot that has no assistant response. +// This handles timeout test scenarios where the snapshot only records the request. +async function isRequestOnlySnapshot( + storedData: NormalizedData, + requestBody: string | undefined, + workDir: string, + toolResultNormalizers: ToolResultNormalizer[], +): Promise { + const normalized = await parseAndNormalizeRequest( + requestBody, + workDir, + toolResultNormalizers, + ); + const requestMessages = normalized.conversations[0]?.messages ?? []; + + for (const conversation of storedData.conversations) { + if ( + requestMessages.length === conversation.messages.length && + requestMessages.every( + (msg, i) => + JSON.stringify(msg) === JSON.stringify(conversation.messages[i]), + ) + ) { + return true; + } + } + return false; +} + async function parseAndNormalizeRequest( requestBody: string | undefined, workDir: string, @@ -607,9 +662,23 @@ function transformOpenAIRequestMessage( } else if (m.role === "user" && typeof m.content === "string") { content = normalizeUserMessage(m.content); } else if (m.role === "tool" && typeof m.content === "string") { - // If it's a JSON tool call result, normalize the whitespace and property ordering + // If it's a JSON tool call result, normalize the whitespace and property ordering. + // For successful tool results wrapped in {resultType, textResultForLlm}, unwrap to + // just the inner value so snapshots stay stable across envelope format changes. try { - content = JSON.stringify(sortJsonKeys(JSON.parse(m.content))); + const parsed = JSON.parse(m.content); + if ( + parsed && + typeof parsed === "object" && + parsed.resultType === "success" && + "textResultForLlm" in parsed + ) { + content = typeof parsed.textResultForLlm === "string" + ? parsed.textResultForLlm + : JSON.stringify(sortJsonKeys(parsed.textResultForLlm)); + } else { + content = JSON.stringify(sortJsonKeys(parsed)); + } } catch { content = m.content.trim(); } @@ -632,6 +701,10 @@ function normalizeUserMessage(content: string): string { return content .replace(/.*?<\/current_datetime>/g, "") .replace(/[\s\S]*?<\/reminder>/g, "") + .replace( + /Please create a detailed summary of the conversation so far\. The history is being compacted[\s\S]*/, + "${compaction_prompt}", + ) .trim(); } @@ -890,9 +963,7 @@ function convertToStreamingResponseChunks( return chunks; } -function createGetModelsResponse(modelIds: string[]): { - data: Awaited>; -} { +function createGetModelsResponse(modelIds: string[]) { // Obviously the following might not match any given model. We could track the original responses from /models, // but that risks invalidating the caches too frequently and making this unmaintainable. If this approximation // turns out to be insufficient, we can tweak the logic here based on known model IDs. diff --git a/test/scenarios/auth/byok-anthropic/go/main.go b/test/scenarios/auth/byok-anthropic/go/main.go index a42f90b8c..048d20f6b 100644 --- a/test/scenarios/auth/byok-anthropic/go/main.go +++ b/test/scenarios/auth/byok-anthropic/go/main.go @@ -49,7 +49,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What is the capital of France?", diff --git a/test/scenarios/auth/byok-anthropic/python/main.py b/test/scenarios/auth/byok-anthropic/python/main.py index 7f5e5834c..e50a33c16 100644 --- a/test/scenarios/auth/byok-anthropic/python/main.py +++ b/test/scenarios/auth/byok-anthropic/python/main.py @@ -40,7 +40,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/auth/byok-anthropic/typescript/src/index.ts b/test/scenarios/auth/byok-anthropic/typescript/src/index.ts index bd5f30dd0..a7f460d8f 100644 --- a/test/scenarios/auth/byok-anthropic/typescript/src/index.ts +++ b/test/scenarios/auth/byok-anthropic/typescript/src/index.ts @@ -36,7 +36,7 @@ async function main() { console.log(response.data.content); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/auth/byok-azure/go/main.go b/test/scenarios/auth/byok-azure/go/main.go index 8d385076e..03f3b9dcf 100644 --- a/test/scenarios/auth/byok-azure/go/main.go +++ b/test/scenarios/auth/byok-azure/go/main.go @@ -53,7 +53,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What is the capital of France?", diff --git a/test/scenarios/auth/byok-azure/python/main.py b/test/scenarios/auth/byok-azure/python/main.py index 5376cac28..89f371789 100644 --- a/test/scenarios/auth/byok-azure/python/main.py +++ b/test/scenarios/auth/byok-azure/python/main.py @@ -44,7 +44,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/auth/byok-azure/typescript/src/index.ts b/test/scenarios/auth/byok-azure/typescript/src/index.ts index 450742f86..397a0a187 100644 --- a/test/scenarios/auth/byok-azure/typescript/src/index.ts +++ b/test/scenarios/auth/byok-azure/typescript/src/index.ts @@ -40,7 +40,7 @@ async function main() { console.log(response.data.content); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/auth/byok-ollama/go/main.go b/test/scenarios/auth/byok-ollama/go/main.go index 191d2eab7..b8b34c5b7 100644 --- a/test/scenarios/auth/byok-ollama/go/main.go +++ b/test/scenarios/auth/byok-ollama/go/main.go @@ -45,7 +45,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What is the capital of France?", diff --git a/test/scenarios/auth/byok-ollama/python/main.py b/test/scenarios/auth/byok-ollama/python/main.py index 0f9df7f54..b86c76ba3 100644 --- a/test/scenarios/auth/byok-ollama/python/main.py +++ b/test/scenarios/auth/byok-ollama/python/main.py @@ -38,7 +38,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/auth/byok-ollama/typescript/src/index.ts b/test/scenarios/auth/byok-ollama/typescript/src/index.ts index 3ba9da89d..936d118a8 100644 --- a/test/scenarios/auth/byok-ollama/typescript/src/index.ts +++ b/test/scenarios/auth/byok-ollama/typescript/src/index.ts @@ -31,7 +31,7 @@ async function main() { console.log(response.data.content); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/auth/byok-openai/go/main.go b/test/scenarios/auth/byok-openai/go/main.go index bd418ab71..fc05c71b4 100644 --- a/test/scenarios/auth/byok-openai/go/main.go +++ b/test/scenarios/auth/byok-openai/go/main.go @@ -44,7 +44,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What is the capital of France?", diff --git a/test/scenarios/auth/byok-openai/python/main.py b/test/scenarios/auth/byok-openai/python/main.py index 651a92cd6..b501bb10e 100644 --- a/test/scenarios/auth/byok-openai/python/main.py +++ b/test/scenarios/auth/byok-openai/python/main.py @@ -35,7 +35,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/auth/byok-openai/typescript/src/index.ts b/test/scenarios/auth/byok-openai/typescript/src/index.ts index 1d2d0aaf8..41eda577a 100644 --- a/test/scenarios/auth/byok-openai/typescript/src/index.ts +++ b/test/scenarios/auth/byok-openai/typescript/src/index.ts @@ -32,7 +32,7 @@ async function main() { console.log(response.data.content); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/auth/gh-app/csharp/Program.cs b/test/scenarios/auth/gh-app/csharp/Program.cs index 70f5f379c..1f2e27ccf 100644 --- a/test/scenarios/auth/gh-app/csharp/Program.cs +++ b/test/scenarios/auth/gh-app/csharp/Program.cs @@ -61,7 +61,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = accessToken, + GitHubToken = accessToken, }); await client.StartAsync(); diff --git a/test/scenarios/auth/gh-app/go/main.go b/test/scenarios/auth/gh-app/go/main.go index d26594779..d84d030cd 100644 --- a/test/scenarios/auth/gh-app/go/main.go +++ b/test/scenarios/auth/gh-app/go/main.go @@ -162,7 +162,7 @@ func main() { } client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: token, + GitHubToken: token, }) ctx := context.Background() @@ -177,7 +177,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What is the capital of France?", diff --git a/test/scenarios/auth/gh-app/python/main.py b/test/scenarios/auth/gh-app/python/main.py index 4568c82b2..4886fe07a 100644 --- a/test/scenarios/auth/gh-app/python/main.py +++ b/test/scenarios/auth/gh-app/python/main.py @@ -88,7 +88,7 @@ async def main(): response = await session.send_and_wait({"prompt": "What is the capital of France?"}) if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/auth/gh-app/typescript/src/index.ts b/test/scenarios/auth/gh-app/typescript/src/index.ts index 1c9cabde3..a5b8f28e2 100644 --- a/test/scenarios/auth/gh-app/typescript/src/index.ts +++ b/test/scenarios/auth/gh-app/typescript/src/index.ts @@ -121,7 +121,7 @@ async function main() { }); if (response) console.log(response.data.content); - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/bundling/app-backend-to-server/go/main.go b/test/scenarios/bundling/app-backend-to-server/go/main.go index afc8858f5..df2be62b9 100644 --- a/test/scenarios/bundling/app-backend-to-server/go/main.go +++ b/test/scenarios/bundling/app-backend-to-server/go/main.go @@ -70,7 +70,7 @@ func chatHandler(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, chatResponse{Error: err.Error()}) return } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: req.Prompt, diff --git a/test/scenarios/bundling/app-backend-to-server/python/main.py b/test/scenarios/bundling/app-backend-to-server/python/main.py index 218505f4a..29563149a 100644 --- a/test/scenarios/bundling/app-backend-to-server/python/main.py +++ b/test/scenarios/bundling/app-backend-to-server/python/main.py @@ -20,7 +20,7 @@ async def ask_copilot(prompt: str) -> str: response = await session.send_and_wait({"prompt": prompt}) - await session.destroy() + await session.disconnect() if response: return response.data.content 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 index 3394c0d3a..7ab734d1a 100644 --- a/test/scenarios/bundling/app-backend-to-server/typescript/src/index.ts +++ b/test/scenarios/bundling/app-backend-to-server/typescript/src/index.ts @@ -21,7 +21,7 @@ app.post("/chat", async (req, res) => { const response = await session.sendAndWait({ prompt }); - await session.destroy(); + await session.disconnect(); if (response?.data.content) { res.json({ response: response.data.content }); diff --git a/test/scenarios/bundling/app-direct-server/go/main.go b/test/scenarios/bundling/app-direct-server/go/main.go index 9a0b1be4e..8be7dd605 100644 --- a/test/scenarios/bundling/app-direct-server/go/main.go +++ b/test/scenarios/bundling/app-direct-server/go/main.go @@ -31,7 +31,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What is the capital of France?", diff --git a/test/scenarios/bundling/app-direct-server/python/main.py b/test/scenarios/bundling/app-direct-server/python/main.py index 05aaa9270..c407d4fea 100644 --- a/test/scenarios/bundling/app-direct-server/python/main.py +++ b/test/scenarios/bundling/app-direct-server/python/main.py @@ -18,7 +18,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/bundling/app-direct-server/typescript/src/index.ts b/test/scenarios/bundling/app-direct-server/typescript/src/index.ts index 139e47a86..29a19dd10 100644 --- a/test/scenarios/bundling/app-direct-server/typescript/src/index.ts +++ b/test/scenarios/bundling/app-direct-server/typescript/src/index.ts @@ -19,7 +19,7 @@ async function main() { process.exit(1); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/bundling/container-proxy/go/main.go b/test/scenarios/bundling/container-proxy/go/main.go index 9a0b1be4e..8be7dd605 100644 --- a/test/scenarios/bundling/container-proxy/go/main.go +++ b/test/scenarios/bundling/container-proxy/go/main.go @@ -31,7 +31,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What is the capital of France?", diff --git a/test/scenarios/bundling/container-proxy/python/main.py b/test/scenarios/bundling/container-proxy/python/main.py index 05aaa9270..c407d4fea 100644 --- a/test/scenarios/bundling/container-proxy/python/main.py +++ b/test/scenarios/bundling/container-proxy/python/main.py @@ -18,7 +18,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/bundling/container-proxy/typescript/src/index.ts b/test/scenarios/bundling/container-proxy/typescript/src/index.ts index 139e47a86..29a19dd10 100644 --- a/test/scenarios/bundling/container-proxy/typescript/src/index.ts +++ b/test/scenarios/bundling/container-proxy/typescript/src/index.ts @@ -19,7 +19,7 @@ async function main() { process.exit(1); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/bundling/fully-bundled/csharp/Program.cs b/test/scenarios/bundling/fully-bundled/csharp/Program.cs index 50505b776..cb67c903c 100644 --- a/test/scenarios/bundling/fully-bundled/csharp/Program.cs +++ b/test/scenarios/bundling/fully-bundled/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/bundling/fully-bundled/go/main.go b/test/scenarios/bundling/fully-bundled/go/main.go index 5543f6b4d..b8902fd99 100644 --- a/test/scenarios/bundling/fully-bundled/go/main.go +++ b/test/scenarios/bundling/fully-bundled/go/main.go @@ -12,7 +12,7 @@ import ( func main() { // Go SDK auto-reads COPILOT_CLI_PATH from env client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -27,7 +27,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What is the capital of France?", diff --git a/test/scenarios/bundling/fully-bundled/python/main.py b/test/scenarios/bundling/fully-bundled/python/main.py index 138bb5646..d1441361f 100644 --- a/test/scenarios/bundling/fully-bundled/python/main.py +++ b/test/scenarios/bundling/fully-bundled/python/main.py @@ -19,7 +19,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/bundling/fully-bundled/typescript/src/index.ts b/test/scenarios/bundling/fully-bundled/typescript/src/index.ts index 989a0b9a6..bee246f64 100644 --- a/test/scenarios/bundling/fully-bundled/typescript/src/index.ts +++ b/test/scenarios/bundling/fully-bundled/typescript/src/index.ts @@ -17,7 +17,7 @@ async function main() { console.log(response.data.content); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/callbacks/hooks/csharp/Program.cs b/test/scenarios/callbacks/hooks/csharp/Program.cs index 14579e3d0..63c15128f 100644 --- a/test/scenarios/callbacks/hooks/csharp/Program.cs +++ b/test/scenarios/callbacks/hooks/csharp/Program.cs @@ -5,7 +5,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); @@ -16,7 +16,7 @@ { Model = "claude-haiku-4.5", OnPermissionRequest = (request, invocation) => - Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }), Hooks = new SessionHooks { OnSessionStart = (input, invocation) => diff --git a/test/scenarios/callbacks/hooks/go/main.go b/test/scenarios/callbacks/hooks/go/main.go index 7b1b1a59b..44e6e0240 100644 --- a/test/scenarios/callbacks/hooks/go/main.go +++ b/test/scenarios/callbacks/hooks/go/main.go @@ -23,7 +23,7 @@ func main() { } client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -67,7 +67,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "List the files in the current directory using the glob tool with pattern '*.md'.", diff --git a/test/scenarios/callbacks/hooks/python/main.py b/test/scenarios/callbacks/hooks/python/main.py index a00c18af7..8df61b9d3 100644 --- a/test/scenarios/callbacks/hooks/python/main.py +++ b/test/scenarios/callbacks/hooks/python/main.py @@ -70,7 +70,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() print("\n--- Hook execution log ---") for entry in hook_log: diff --git a/test/scenarios/callbacks/hooks/typescript/src/index.ts b/test/scenarios/callbacks/hooks/typescript/src/index.ts index 52708d8fd..2a5cde585 100644 --- a/test/scenarios/callbacks/hooks/typescript/src/index.ts +++ b/test/scenarios/callbacks/hooks/typescript/src/index.ts @@ -44,7 +44,7 @@ async function main() { console.log(response.data.content); } - await session.destroy(); + await session.disconnect(); console.log("\n--- Hook execution log ---"); for (const entry of hookLog) { diff --git a/test/scenarios/callbacks/permissions/csharp/Program.cs b/test/scenarios/callbacks/permissions/csharp/Program.cs index be00015a9..0000ed575 100644 --- a/test/scenarios/callbacks/permissions/csharp/Program.cs +++ b/test/scenarios/callbacks/permissions/csharp/Program.cs @@ -5,7 +5,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); @@ -21,7 +21,7 @@ ? value?.ToString() ?? "unknown" : "unknown"; permissionLog.Add($"approved:{toolName}"); - return Task.FromResult(new PermissionRequestResult { Kind = "approved" }); + return Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }); }, Hooks = new SessionHooks { diff --git a/test/scenarios/callbacks/permissions/go/main.go b/test/scenarios/callbacks/permissions/go/main.go index 7dad320c3..a09bbf21d 100644 --- a/test/scenarios/callbacks/permissions/go/main.go +++ b/test/scenarios/callbacks/permissions/go/main.go @@ -17,7 +17,7 @@ func main() { ) client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -30,7 +30,10 @@ func main() { Model: "claude-haiku-4.5", OnPermissionRequest: func(req copilot.PermissionRequest, inv copilot.PermissionInvocation) (copilot.PermissionRequestResult, error) { permissionLogMu.Lock() - toolName, _ := req.Extra["toolName"].(string) + toolName := "" + if req.ToolName != nil { + toolName = *req.ToolName + } permissionLog = append(permissionLog, fmt.Sprintf("approved:%s", toolName)) permissionLogMu.Unlock() return copilot.PermissionRequestResult{Kind: "approved"}, nil @@ -44,7 +47,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "List the files in the current directory using glob with pattern '*.md'.", diff --git a/test/scenarios/callbacks/permissions/python/main.py b/test/scenarios/callbacks/permissions/python/main.py index 2da5133fa..9674da917 100644 --- a/test/scenarios/callbacks/permissions/python/main.py +++ b/test/scenarios/callbacks/permissions/python/main.py @@ -39,7 +39,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() print("\n--- Permission request log ---") for entry in permission_log: diff --git a/test/scenarios/callbacks/permissions/typescript/src/index.ts b/test/scenarios/callbacks/permissions/typescript/src/index.ts index a7e452cc7..6a163bc27 100644 --- a/test/scenarios/callbacks/permissions/typescript/src/index.ts +++ b/test/scenarios/callbacks/permissions/typescript/src/index.ts @@ -31,7 +31,7 @@ async function main() { console.log(response.data.content); } - await session.destroy(); + await session.disconnect(); console.log("\n--- Permission request log ---"); for (const entry of permissionLog) { diff --git a/test/scenarios/callbacks/user-input/csharp/Program.cs b/test/scenarios/callbacks/user-input/csharp/Program.cs index 0ffed2469..6ad0454d7 100644 --- a/test/scenarios/callbacks/user-input/csharp/Program.cs +++ b/test/scenarios/callbacks/user-input/csharp/Program.cs @@ -5,7 +5,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); @@ -16,7 +16,7 @@ { Model = "claude-haiku-4.5", OnPermissionRequest = (request, invocation) => - Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }), OnUserInputRequest = (request, invocation) => { inputLog.Add($"question: {request.Question}"); diff --git a/test/scenarios/callbacks/user-input/go/main.go b/test/scenarios/callbacks/user-input/go/main.go index 9405de035..50eb65a23 100644 --- a/test/scenarios/callbacks/user-input/go/main.go +++ b/test/scenarios/callbacks/user-input/go/main.go @@ -17,7 +17,7 @@ var ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -46,7 +46,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "I want to learn about a city. Use the ask_user tool to ask me " + diff --git a/test/scenarios/callbacks/user-input/python/main.py b/test/scenarios/callbacks/user-input/python/main.py index fb36eda5c..dc8d9fa9b 100644 --- a/test/scenarios/callbacks/user-input/python/main.py +++ b/test/scenarios/callbacks/user-input/python/main.py @@ -47,7 +47,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() print("\n--- User input log ---") for entry in input_log: diff --git a/test/scenarios/callbacks/user-input/typescript/src/index.ts b/test/scenarios/callbacks/user-input/typescript/src/index.ts index 4791fcf10..5964ce6c1 100644 --- a/test/scenarios/callbacks/user-input/typescript/src/index.ts +++ b/test/scenarios/callbacks/user-input/typescript/src/index.ts @@ -29,7 +29,7 @@ async function main() { console.log(response.data.content); } - await session.destroy(); + await session.disconnect(); console.log("\n--- User input log ---"); for (const entry of inputLog) { diff --git a/test/scenarios/modes/default/csharp/Program.cs b/test/scenarios/modes/default/csharp/Program.cs index 974a93036..243fcb922 100644 --- a/test/scenarios/modes/default/csharp/Program.cs +++ b/test/scenarios/modes/default/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/modes/default/go/main.go b/test/scenarios/modes/default/go/main.go index b17ac1e88..dd2b45d33 100644 --- a/test/scenarios/modes/default/go/main.go +++ b/test/scenarios/modes/default/go/main.go @@ -11,7 +11,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -26,7 +26,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() 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.", diff --git a/test/scenarios/modes/default/python/main.py b/test/scenarios/modes/default/python/main.py index 0abc6b709..dadc0e7be 100644 --- a/test/scenarios/modes/default/python/main.py +++ b/test/scenarios/modes/default/python/main.py @@ -20,7 +20,7 @@ async def main(): print("Default mode test complete") - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/modes/default/typescript/src/index.ts b/test/scenarios/modes/default/typescript/src/index.ts index e10cb6cbc..89aab3598 100644 --- a/test/scenarios/modes/default/typescript/src/index.ts +++ b/test/scenarios/modes/default/typescript/src/index.ts @@ -21,7 +21,7 @@ async function main() { console.log("Default mode test complete"); - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/modes/minimal/csharp/Program.cs b/test/scenarios/modes/minimal/csharp/Program.cs index 626e13970..94cbc2034 100644 --- a/test/scenarios/modes/minimal/csharp/Program.cs +++ b/test/scenarios/modes/minimal/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/modes/minimal/go/main.go b/test/scenarios/modes/minimal/go/main.go index 1e6d46a53..c3624b114 100644 --- a/test/scenarios/modes/minimal/go/main.go +++ b/test/scenarios/modes/minimal/go/main.go @@ -11,7 +11,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -31,7 +31,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "Use the grep tool to search for 'SDK' in README.md.", diff --git a/test/scenarios/modes/minimal/python/main.py b/test/scenarios/modes/minimal/python/main.py index 74a98ba0e..0b243cafa 100644 --- a/test/scenarios/modes/minimal/python/main.py +++ b/test/scenarios/modes/minimal/python/main.py @@ -25,7 +25,7 @@ async def main(): print("Minimal mode test complete") - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/modes/minimal/typescript/src/index.ts b/test/scenarios/modes/minimal/typescript/src/index.ts index 091595bec..f20e476de 100644 --- a/test/scenarios/modes/minimal/typescript/src/index.ts +++ b/test/scenarios/modes/minimal/typescript/src/index.ts @@ -26,7 +26,7 @@ async function main() { console.log("Minimal mode test complete"); - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/prompts/attachments/csharp/Program.cs b/test/scenarios/prompts/attachments/csharp/Program.cs index 9e28c342d..357444a6f 100644 --- a/test/scenarios/prompts/attachments/csharp/Program.cs +++ b/test/scenarios/prompts/attachments/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/prompts/attachments/go/main.go b/test/scenarios/prompts/attachments/go/main.go index bb1486da2..95eb2b4d0 100644 --- a/test/scenarios/prompts/attachments/go/main.go +++ b/test/scenarios/prompts/attachments/go/main.go @@ -14,7 +14,7 @@ const systemPrompt = `You are a helpful assistant. Answer questions about attach func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -34,7 +34,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() exe, err := os.Executable() if err != nil { diff --git a/test/scenarios/prompts/attachments/python/main.py b/test/scenarios/prompts/attachments/python/main.py index acf9c7af1..c7e21e8b9 100644 --- a/test/scenarios/prompts/attachments/python/main.py +++ b/test/scenarios/prompts/attachments/python/main.py @@ -33,7 +33,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/prompts/attachments/typescript/src/index.ts b/test/scenarios/prompts/attachments/typescript/src/index.ts index 72e601ca2..100f7e17d 100644 --- a/test/scenarios/prompts/attachments/typescript/src/index.ts +++ b/test/scenarios/prompts/attachments/typescript/src/index.ts @@ -31,7 +31,7 @@ async function main() { console.log(response.data.content); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/prompts/reasoning-effort/csharp/Program.cs b/test/scenarios/prompts/reasoning-effort/csharp/Program.cs index c026e046d..719650880 100644 --- a/test/scenarios/prompts/reasoning-effort/csharp/Program.cs +++ b/test/scenarios/prompts/reasoning-effort/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/prompts/reasoning-effort/go/main.go b/test/scenarios/prompts/reasoning-effort/go/main.go index ce9ffe508..ccb4e5284 100644 --- a/test/scenarios/prompts/reasoning-effort/go/main.go +++ b/test/scenarios/prompts/reasoning-effort/go/main.go @@ -11,7 +11,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -32,7 +32,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What is the capital of France?", diff --git a/test/scenarios/prompts/reasoning-effort/python/main.py b/test/scenarios/prompts/reasoning-effort/python/main.py index 74444e7bf..b38452a89 100644 --- a/test/scenarios/prompts/reasoning-effort/python/main.py +++ b/test/scenarios/prompts/reasoning-effort/python/main.py @@ -28,7 +28,7 @@ async def main(): print("Reasoning effort: low") print(f"Response: {response.data.content}") - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts b/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts index fd2091ef0..e569fd705 100644 --- a/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts +++ b/test/scenarios/prompts/reasoning-effort/typescript/src/index.ts @@ -27,7 +27,7 @@ async function main() { console.log(`Response: ${response.data.content}`); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/prompts/system-message/csharp/Program.cs b/test/scenarios/prompts/system-message/csharp/Program.cs index 7b13d173c..5f22cb029 100644 --- a/test/scenarios/prompts/system-message/csharp/Program.cs +++ b/test/scenarios/prompts/system-message/csharp/Program.cs @@ -5,7 +5,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/prompts/system-message/go/main.go b/test/scenarios/prompts/system-message/go/main.go index 34e9c7523..074c9994b 100644 --- a/test/scenarios/prompts/system-message/go/main.go +++ b/test/scenarios/prompts/system-message/go/main.go @@ -13,7 +13,7 @@ const piratePrompt = `You are a pirate. Always respond in pirate speak. Say 'Arr func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -33,7 +33,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What is the capital of France?", diff --git a/test/scenarios/prompts/system-message/python/main.py b/test/scenarios/prompts/system-message/python/main.py index a3bfccdcf..5e396c8cd 100644 --- a/test/scenarios/prompts/system-message/python/main.py +++ b/test/scenarios/prompts/system-message/python/main.py @@ -27,7 +27,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/prompts/system-message/typescript/src/index.ts b/test/scenarios/prompts/system-message/typescript/src/index.ts index dc518069b..e0eb0aab7 100644 --- a/test/scenarios/prompts/system-message/typescript/src/index.ts +++ b/test/scenarios/prompts/system-message/typescript/src/index.ts @@ -23,7 +23,7 @@ async function main() { console.log(response.data.content); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/sessions/concurrent-sessions/csharp/Program.cs b/test/scenarios/sessions/concurrent-sessions/csharp/Program.cs index f3f1b3688..142bcb268 100644 --- a/test/scenarios/sessions/concurrent-sessions/csharp/Program.cs +++ b/test/scenarios/sessions/concurrent-sessions/csharp/Program.cs @@ -6,7 +6,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/sessions/concurrent-sessions/go/main.go b/test/scenarios/sessions/concurrent-sessions/go/main.go index fa15f445e..ced915531 100644 --- a/test/scenarios/sessions/concurrent-sessions/go/main.go +++ b/test/scenarios/sessions/concurrent-sessions/go/main.go @@ -15,7 +15,7 @@ const robotPrompt = `You are a robot. Always say BEEP BOOP!` func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -35,7 +35,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session1.Destroy() + defer session1.Disconnect() session2, err := client.CreateSession(ctx, &copilot.SessionConfig{ Model: "claude-haiku-4.5", @@ -48,7 +48,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session2.Destroy() + defer session2.Disconnect() type result struct { label string diff --git a/test/scenarios/sessions/concurrent-sessions/python/main.py b/test/scenarios/sessions/concurrent-sessions/python/main.py index 171a202e4..ebca89901 100644 --- a/test/scenarios/sessions/concurrent-sessions/python/main.py +++ b/test/scenarios/sessions/concurrent-sessions/python/main.py @@ -44,7 +44,7 @@ async def main(): if response2: print("Session 2 (robot):", response2.data.content) - await asyncio.gather(session1.destroy(), session2.destroy()) + await asyncio.gather(session1.disconnect(), session2.disconnect()) finally: await client.stop() diff --git a/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts b/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts index 80772886a..89543d281 100644 --- a/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts +++ b/test/scenarios/sessions/concurrent-sessions/typescript/src/index.ts @@ -35,7 +35,7 @@ async function main() { console.log("Session 2 (robot):", response2.data.content); } - await Promise.all([session1.destroy(), session2.destroy()]); + await Promise.all([session1.disconnect(), session2.disconnect()]); } finally { await client.stop(); process.exit(0); diff --git a/test/scenarios/sessions/infinite-sessions/csharp/Program.cs b/test/scenarios/sessions/infinite-sessions/csharp/Program.cs index 1c6244e4d..fe281292d 100644 --- a/test/scenarios/sessions/infinite-sessions/csharp/Program.cs +++ b/test/scenarios/sessions/infinite-sessions/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/sessions/infinite-sessions/go/main.go b/test/scenarios/sessions/infinite-sessions/go/main.go index c4c95814c..540f8f6b4 100644 --- a/test/scenarios/sessions/infinite-sessions/go/main.go +++ b/test/scenarios/sessions/infinite-sessions/go/main.go @@ -14,7 +14,7 @@ func float64Ptr(f float64) *float64 { return &f } func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -39,7 +39,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() prompts := []string{ "What is the capital of France?", diff --git a/test/scenarios/sessions/infinite-sessions/python/main.py b/test/scenarios/sessions/infinite-sessions/python/main.py index fe39a7117..23749d06f 100644 --- a/test/scenarios/sessions/infinite-sessions/python/main.py +++ b/test/scenarios/sessions/infinite-sessions/python/main.py @@ -38,7 +38,7 @@ async def main(): print("Infinite sessions test complete — all messages processed successfully") - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts b/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts index a3b3de61c..9de7b34f7 100644 --- a/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts +++ b/test/scenarios/sessions/infinite-sessions/typescript/src/index.ts @@ -37,7 +37,7 @@ async function main() { console.log("Infinite sessions test complete — all messages processed successfully"); - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/sessions/multi-user-short-lived/README.md b/test/scenarios/sessions/multi-user-short-lived/README.md index 6596fa7bb..17e7e1278 100644 --- a/test/scenarios/sessions/multi-user-short-lived/README.md +++ b/test/scenarios/sessions/multi-user-short-lived/README.md @@ -17,7 +17,7 @@ Demonstrates a **stateless backend pattern** where multiple users interact with │(new) │ │(new)│ │(new) │ └──────┘ └─────┘ └──────┘ -Each request → new session → destroy after response +Each request → new session → disconnect after response Virtual FS per user (in-memory, not shared across users) ``` diff --git a/test/scenarios/sessions/session-resume/csharp/Program.cs b/test/scenarios/sessions/session-resume/csharp/Program.cs index 743873afe..73979669d 100644 --- a/test/scenarios/sessions/session-resume/csharp/Program.cs +++ b/test/scenarios/sessions/session-resume/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); @@ -13,6 +13,7 @@ // 1. Create a session await using var session = await client.CreateSessionAsync(new SessionConfig { + OnPermissionRequest = PermissionHandler.ApproveAll, Model = "claude-haiku-4.5", AvailableTools = new List(), }); @@ -27,7 +28,10 @@ await session.SendAndWaitAsync(new MessageOptions var sessionId = session.SessionId; // 4. Resume the session with the same ID - await using var resumed = await client.ResumeSessionAsync(sessionId); + await using var resumed = await client.ResumeSessionAsync(sessionId, new ResumeSessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); Console.WriteLine("Session resumed"); // 5. Ask for the secret word diff --git a/test/scenarios/sessions/session-resume/go/main.go b/test/scenarios/sessions/session-resume/go/main.go index cf2cb0448..2ba0b24bc 100644 --- a/test/scenarios/sessions/session-resume/go/main.go +++ b/test/scenarios/sessions/session-resume/go/main.go @@ -11,7 +11,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -22,8 +22,9 @@ func main() { // 1. Create a session session, err := client.CreateSession(ctx, &copilot.SessionConfig{ - Model: "claude-haiku-4.5", - AvailableTools: []string{}, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Model: "claude-haiku-4.5", + AvailableTools: []string{}, }) if err != nil { log.Fatal(err) @@ -37,16 +38,18 @@ func main() { log.Fatal(err) } - // 3. Get the session ID (don't destroy — resume needs the session to persist) + // 3. Get the session ID (don't disconnect — resume needs the session to persist) sessionID := session.SessionID // 4. Resume the session with the same ID - resumed, err := client.ResumeSession(ctx, sessionID) + resumed, err := client.ResumeSession(ctx, sessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) if err != nil { log.Fatal(err) } fmt.Println("Session resumed") - defer resumed.Destroy() + defer resumed.Disconnect() // 5. Ask for the secret word response, err := resumed.SendAndWait(ctx, copilot.MessageOptions{ diff --git a/test/scenarios/sessions/session-resume/python/main.py b/test/scenarios/sessions/session-resume/python/main.py index b65370b97..7eb5e0cae 100644 --- a/test/scenarios/sessions/session-resume/python/main.py +++ b/test/scenarios/sessions/session-resume/python/main.py @@ -23,7 +23,7 @@ async def main(): {"prompt": "Remember this: the secret word is PINEAPPLE."} ) - # 3. Get the session ID (don't destroy — resume needs the session to persist) + # 3. Get the session ID (don't disconnect — resume needs the session to persist) session_id = session.session_id # 4. Resume the session with the same ID @@ -38,7 +38,7 @@ async def main(): if response: print(response.data.content) - await resumed.destroy() + await resumed.disconnect() finally: await client.stop() diff --git a/test/scenarios/sessions/session-resume/typescript/src/index.ts b/test/scenarios/sessions/session-resume/typescript/src/index.ts index 7d08f40ef..9e0a16859 100644 --- a/test/scenarios/sessions/session-resume/typescript/src/index.ts +++ b/test/scenarios/sessions/session-resume/typescript/src/index.ts @@ -18,7 +18,7 @@ async function main() { prompt: "Remember this: the secret word is PINEAPPLE.", }); - // 3. Get the session ID (don't destroy — resume needs the session to persist) + // 3. Get the session ID (don't disconnect — resume needs the session to persist) const sessionId = session.sessionId; // 4. Resume the session with the same ID @@ -34,7 +34,7 @@ async function main() { console.log(response.data.content); } - await resumed.destroy(); + await resumed.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/sessions/streaming/csharp/Program.cs b/test/scenarios/sessions/streaming/csharp/Program.cs index b7c1e0ff5..01683df76 100644 --- a/test/scenarios/sessions/streaming/csharp/Program.cs +++ b/test/scenarios/sessions/streaming/csharp/Program.cs @@ -2,7 +2,7 @@ var options = new CopilotClientOptions { - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }; var cliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"); diff --git a/test/scenarios/sessions/streaming/go/main.go b/test/scenarios/sessions/streaming/go/main.go index 0f55ece43..6243a1662 100644 --- a/test/scenarios/sessions/streaming/go/main.go +++ b/test/scenarios/sessions/streaming/go/main.go @@ -11,7 +11,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -27,7 +27,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() chunkCount := 0 session.On(func(event copilot.SessionEvent) { diff --git a/test/scenarios/sessions/streaming/python/main.py b/test/scenarios/sessions/streaming/python/main.py index 2bbc94e78..94569de11 100644 --- a/test/scenarios/sessions/streaming/python/main.py +++ b/test/scenarios/sessions/streaming/python/main.py @@ -34,7 +34,7 @@ def on_event(event): print(response.data.content) print(f"\nStreaming chunks received: {chunk_count}") - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/sessions/streaming/typescript/src/index.ts b/test/scenarios/sessions/streaming/typescript/src/index.ts index fb0a23bed..f70dcccec 100644 --- a/test/scenarios/sessions/streaming/typescript/src/index.ts +++ b/test/scenarios/sessions/streaming/typescript/src/index.ts @@ -26,7 +26,7 @@ async function main() { } console.log(`\nStreaming chunks received: ${chunkCount}`); - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/tools/custom-agents/csharp/Program.cs b/test/scenarios/tools/custom-agents/csharp/Program.cs index 394de465f..c5c6525f1 100644 --- a/test/scenarios/tools/custom-agents/csharp/Program.cs +++ b/test/scenarios/tools/custom-agents/csharp/Program.cs @@ -5,7 +5,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = cliPath, - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/tools/custom-agents/go/main.go b/test/scenarios/tools/custom-agents/go/main.go index 321793382..f2add8224 100644 --- a/test/scenarios/tools/custom-agents/go/main.go +++ b/test/scenarios/tools/custom-agents/go/main.go @@ -11,7 +11,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -35,7 +35,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What custom agents are available? Describe the researcher agent and its capabilities.", diff --git a/test/scenarios/tools/custom-agents/python/main.py b/test/scenarios/tools/custom-agents/python/main.py index d4e416716..0b5f073d5 100644 --- a/test/scenarios/tools/custom-agents/python/main.py +++ b/test/scenarios/tools/custom-agents/python/main.py @@ -32,7 +32,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/tools/custom-agents/typescript/src/index.ts b/test/scenarios/tools/custom-agents/typescript/src/index.ts index b098bffa8..f6e163256 100644 --- a/test/scenarios/tools/custom-agents/typescript/src/index.ts +++ b/test/scenarios/tools/custom-agents/typescript/src/index.ts @@ -28,7 +28,7 @@ async function main() { console.log(response.data.content); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/tools/mcp-servers/csharp/Program.cs b/test/scenarios/tools/mcp-servers/csharp/Program.cs index 1d5acbd2e..2ee25aacd 100644 --- a/test/scenarios/tools/mcp-servers/csharp/Program.cs +++ b/test/scenarios/tools/mcp-servers/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/tools/mcp-servers/go/main.go b/test/scenarios/tools/mcp-servers/go/main.go index 15ffa4c41..a6e2e9c1f 100644 --- a/test/scenarios/tools/mcp-servers/go/main.go +++ b/test/scenarios/tools/mcp-servers/go/main.go @@ -12,7 +12,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -53,7 +53,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What is the capital of France?", diff --git a/test/scenarios/tools/mcp-servers/python/main.py b/test/scenarios/tools/mcp-servers/python/main.py index 81d2e39ba..f092fb9a8 100644 --- a/test/scenarios/tools/mcp-servers/python/main.py +++ b/test/scenarios/tools/mcp-servers/python/main.py @@ -47,7 +47,7 @@ async def main(): else: print("\nNo MCP servers configured (set MCP_SERVER_CMD to test with a real server)") - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/tools/mcp-servers/typescript/src/index.ts b/test/scenarios/tools/mcp-servers/typescript/src/index.ts index 41afa5837..1e8c11466 100644 --- a/test/scenarios/tools/mcp-servers/typescript/src/index.ts +++ b/test/scenarios/tools/mcp-servers/typescript/src/index.ts @@ -43,7 +43,7 @@ async function main() { console.log("\nNo MCP servers configured (set MCP_SERVER_CMD to test with a real server)"); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/tools/no-tools/csharp/Program.cs b/test/scenarios/tools/no-tools/csharp/Program.cs index d25b57a6c..c3de1de53 100644 --- a/test/scenarios/tools/no-tools/csharp/Program.cs +++ b/test/scenarios/tools/no-tools/csharp/Program.cs @@ -10,7 +10,7 @@ You can only respond with text based on your training data. using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/tools/no-tools/go/main.go b/test/scenarios/tools/no-tools/go/main.go index 75cfa894d..62af3bcea 100644 --- a/test/scenarios/tools/no-tools/go/main.go +++ b/test/scenarios/tools/no-tools/go/main.go @@ -16,7 +16,7 @@ If asked about your capabilities or tools, clearly state that you have no tools func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -36,7 +36,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "Use the bash tool to run 'echo hello'.", diff --git a/test/scenarios/tools/no-tools/python/main.py b/test/scenarios/tools/no-tools/python/main.py index d857183c0..a3824bab7 100644 --- a/test/scenarios/tools/no-tools/python/main.py +++ b/test/scenarios/tools/no-tools/python/main.py @@ -30,7 +30,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/tools/no-tools/typescript/src/index.ts b/test/scenarios/tools/no-tools/typescript/src/index.ts index dea9c4f14..487b47622 100644 --- a/test/scenarios/tools/no-tools/typescript/src/index.ts +++ b/test/scenarios/tools/no-tools/typescript/src/index.ts @@ -26,7 +26,7 @@ async function main() { console.log(response.data.content); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/tools/skills/csharp/Program.cs b/test/scenarios/tools/skills/csharp/Program.cs index fc31c2940..d0394a396 100644 --- a/test/scenarios/tools/skills/csharp/Program.cs +++ b/test/scenarios/tools/skills/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); @@ -17,7 +17,7 @@ Model = "claude-haiku-4.5", SkillDirectories = [skillsDir], OnPermissionRequest = (request, invocation) => - Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }), Hooks = new SessionHooks { OnPreToolUse = (input, invocation) => diff --git a/test/scenarios/tools/skills/go/main.go b/test/scenarios/tools/skills/go/main.go index d0d9f8700..5652de329 100644 --- a/test/scenarios/tools/skills/go/main.go +++ b/test/scenarios/tools/skills/go/main.go @@ -13,7 +13,7 @@ import ( func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -40,7 +40,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "Use the greeting skill to greet someone named Alice.", diff --git a/test/scenarios/tools/skills/python/main.py b/test/scenarios/tools/skills/python/main.py index 5adb74b76..3e06650b5 100644 --- a/test/scenarios/tools/skills/python/main.py +++ b/test/scenarios/tools/skills/python/main.py @@ -34,7 +34,7 @@ async def main(): print("\nSkill directories configured successfully") - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/tools/skills/typescript/src/index.ts b/test/scenarios/tools/skills/typescript/src/index.ts index fa4b33727..de7f13568 100644 --- a/test/scenarios/tools/skills/typescript/src/index.ts +++ b/test/scenarios/tools/skills/typescript/src/index.ts @@ -32,7 +32,7 @@ async function main() { console.log("\nSkill directories configured successfully"); - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/tools/tool-filtering/csharp/Program.cs b/test/scenarios/tools/tool-filtering/csharp/Program.cs index dfe3b5a93..f21482b1b 100644 --- a/test/scenarios/tools/tool-filtering/csharp/Program.cs +++ b/test/scenarios/tools/tool-filtering/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/tools/tool-filtering/go/main.go b/test/scenarios/tools/tool-filtering/go/main.go index 3c31c198e..851ca3111 100644 --- a/test/scenarios/tools/tool-filtering/go/main.go +++ b/test/scenarios/tools/tool-filtering/go/main.go @@ -13,7 +13,7 @@ const systemPrompt = `You are a helpful assistant. You have access to a limited func main() { client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -33,7 +33,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What tools do you have available? List each one by name.", diff --git a/test/scenarios/tools/tool-filtering/python/main.py b/test/scenarios/tools/tool-filtering/python/main.py index 174be620e..1fdfacc76 100644 --- a/test/scenarios/tools/tool-filtering/python/main.py +++ b/test/scenarios/tools/tool-filtering/python/main.py @@ -27,7 +27,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/tools/tool-filtering/typescript/src/index.ts b/test/scenarios/tools/tool-filtering/typescript/src/index.ts index 40cc91124..9976e38f8 100644 --- a/test/scenarios/tools/tool-filtering/typescript/src/index.ts +++ b/test/scenarios/tools/tool-filtering/typescript/src/index.ts @@ -24,7 +24,7 @@ async function main() { console.log(response.data.content); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/tools/tool-overrides/README.md b/test/scenarios/tools/tool-overrides/README.md new file mode 100644 index 000000000..45f75dc86 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/README.md @@ -0,0 +1,32 @@ +# Config Sample: Tool Overrides + +Demonstrates how to override a built-in tool with a custom implementation using the `overridesBuiltInTool` flag. When this flag is set on a custom tool, the SDK knows to disable the corresponding built-in tool so your implementation is used instead. + +## What Each Sample Does + +1. Creates a session with a custom `grep` tool (with `overridesBuiltInTool` enabled) that returns `"CUSTOM_GREP_RESULT: "` +2. Sends: _"Use grep to search for the word 'hello'"_ +3. Prints the response — which should contain `CUSTOM_GREP_RESULT` (proving the custom tool ran, not the built-in) + +## Configuration + +| Option | Value | Effect | +|--------|-------|--------| +| `tools` | Custom `grep` tool | Provides a custom grep implementation | +| `overridesBuiltInTool` | `true` | Tells the SDK to disable the built-in `grep` in favor of the custom one | + +The flag is set per-tool in TypeScript (`overridesBuiltInTool: true`), Python (`overrides_built_in_tool=True`), and Go (`OverridesBuiltInTool: true`). In C#, set `is_override` in the tool's `AdditionalProperties` via `AIFunctionFactoryOptions`. + +## 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 contains `CUSTOM_GREP_RESULT` (custom tool was invoked) +- The response does **not** contain typical built-in grep output patterns diff --git a/test/scenarios/tools/tool-overrides/csharp/Program.cs b/test/scenarios/tools/tool-overrides/csharp/Program.cs new file mode 100644 index 000000000..42ad433fe --- /dev/null +++ b/test/scenarios/tools/tool-overrides/csharp/Program.cs @@ -0,0 +1,45 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using GitHub.Copilot.SDK; +using Microsoft.Extensions.AI; + +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 = PermissionHandler.ApproveAll, + Tools = [AIFunctionFactory.Create((Delegate)CustomGrep, new AIFunctionFactoryOptions + { + Name = "grep", + AdditionalProperties = new ReadOnlyDictionary( + new Dictionary { ["is_override"] = true }) + })], + }); + + var response = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Use grep to search for the word 'hello'", + }); + + if (response != null) + { + Console.WriteLine(response.Data?.Content); + } +} +finally +{ + await client.StopAsync(); +} + +[Description("A custom grep implementation that overrides the built-in")] +static string CustomGrep([Description("Search query")] string query) + => $"CUSTOM_GREP_RESULT: {query}"; diff --git a/test/scenarios/tools/tool-overrides/csharp/csharp.csproj b/test/scenarios/tools/tool-overrides/csharp/csharp.csproj new file mode 100644 index 000000000..48e375961 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/csharp/csharp.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + LatestMajor + enable + enable + true + + + + + diff --git a/test/scenarios/tools/tool-overrides/go/go.mod b/test/scenarios/tools/tool-overrides/go/go.mod new file mode 100644 index 000000000..353066761 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/go/go.mod @@ -0,0 +1,9 @@ +module github.com/github/copilot-sdk/samples/tools/tool-overrides/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-overrides/go/go.sum b/test/scenarios/tools/tool-overrides/go/go.sum new file mode 100644 index 000000000..6e171099c --- /dev/null +++ b/test/scenarios/tools/tool-overrides/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-overrides/go/main.go b/test/scenarios/tools/tool-overrides/go/main.go new file mode 100644 index 000000000..75b7698c0 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/go/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + copilot "github.com/github/copilot-sdk/go" +) + +type GrepParams struct { + Query string `json:"query" jsonschema:"Search query"` +} + +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() + + grepTool := copilot.DefineTool("grep", "A custom grep implementation that overrides the built-in", + func(params GrepParams, inv copilot.ToolInvocation) (string, error) { + return "CUSTOM_GREP_RESULT: " + params.Query, nil + }) + grepTool.OverridesBuiltInTool = true + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: "claude-haiku-4.5", + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Tools: []copilot.Tool{grepTool}, + }) + if err != nil { + log.Fatal(err) + } + defer session.Disconnect() + + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "Use grep to search for the word '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/tool-overrides/python/main.py b/test/scenarios/tools/tool-overrides/python/main.py new file mode 100644 index 000000000..1f1099f0d --- /dev/null +++ b/test/scenarios/tools/tool-overrides/python/main.py @@ -0,0 +1,45 @@ +import asyncio +import os + +from pydantic import BaseModel, Field + +from copilot import CopilotClient, PermissionHandler, define_tool + + +class GrepParams(BaseModel): + query: str = Field(description="Search query") + + +@define_tool("grep", description="A custom grep implementation that overrides the built-in", overrides_built_in_tool=True) +def custom_grep(params: GrepParams) -> str: + return f"CUSTOM_GREP_RESULT: {params.query}" + + +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", + "tools": [custom_grep], + "on_permission_request": PermissionHandler.approve_all, + } + ) + + response = await session.send_and_wait( + {"prompt": "Use grep to search for the word 'hello'"} + ) + + if response: + print(response.data.content) + + await session.disconnect() + finally: + await client.stop() + + +asyncio.run(main()) diff --git a/test/scenarios/tools/tool-overrides/python/requirements.txt b/test/scenarios/tools/tool-overrides/python/requirements.txt new file mode 100644 index 000000000..f9a8f4d60 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/python/requirements.txt @@ -0,0 +1 @@ +-e ../../../../../python diff --git a/test/scenarios/tools/tool-overrides/typescript/package.json b/test/scenarios/tools/tool-overrides/typescript/package.json new file mode 100644 index 000000000..64e958406 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/typescript/package.json @@ -0,0 +1,18 @@ +{ + "name": "tools-tool-overrides-typescript", + "version": "1.0.0", + "private": true, + "description": "Config sample — custom tool overriding a built-in 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/tools/tool-overrides/typescript/src/index.ts b/test/scenarios/tools/tool-overrides/typescript/src/index.ts new file mode 100644 index 000000000..0472115d5 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/typescript/src/index.ts @@ -0,0 +1,43 @@ +import { CopilotClient, defineTool, approveAll } from "@github/copilot-sdk"; +import { z } from "zod"; + +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", + onPermissionRequest: approveAll, + tools: [ + defineTool("grep", { + description: "A custom grep implementation that overrides the built-in", + parameters: z.object({ + query: z.string().describe("Search query"), + }), + overridesBuiltInTool: true, + handler: ({ query }) => `CUSTOM_GREP_RESULT: ${query}`, + }), + ], + }); + + const response = await session.sendAndWait({ + prompt: "Use grep to search for the word 'hello'", + }); + + if (response) { + console.log(response.data.content); + } + + await session.disconnect(); + } finally { + await client.stop(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/scenarios/tools/tool-overrides/verify.sh b/test/scenarios/tools/tool-overrides/verify.sh new file mode 100755 index 000000000..b7687de50 --- /dev/null +++ b/test/scenarios/tools/tool-overrides/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 custom grep tool was used (not built-in) + if [ "$code" -eq 0 ] && [ -n "$output" ]; then + if echo "$output" | grep -q "CUSTOM_GREP_RESULT"; then + echo "✅ $name passed (confirmed custom tool override)" + PASS=$((PASS + 1)) + else + echo "⚠️ $name ran but response doesn't contain CUSTOM_GREP_RESULT" + 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-overrides 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-overrides-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-overrides-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/csharp/Program.cs b/test/scenarios/tools/virtual-filesystem/csharp/Program.cs index 4018b5f99..d67a3738c 100644 --- a/test/scenarios/tools/virtual-filesystem/csharp/Program.cs +++ b/test/scenarios/tools/virtual-filesystem/csharp/Program.cs @@ -8,7 +8,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); @@ -49,7 +49,7 @@ "List all files in the virtual filesystem"), ], OnPermissionRequest = (request, invocation) => - Task.FromResult(new PermissionRequestResult { Kind = "approved" }), + Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }), Hooks = new SessionHooks { OnPreToolUse = (input, invocation) => diff --git a/test/scenarios/tools/virtual-filesystem/go/main.go b/test/scenarios/tools/virtual-filesystem/go/main.go index 625d999ea..39e3d910e 100644 --- a/test/scenarios/tools/virtual-filesystem/go/main.go +++ b/test/scenarios/tools/virtual-filesystem/go/main.go @@ -74,7 +74,7 @@ func main() { } client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -100,7 +100,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "Create a file called plan.md with a brief 3-item project plan " + diff --git a/test/scenarios/tools/virtual-filesystem/python/main.py b/test/scenarios/tools/virtual-filesystem/python/main.py index b150c1a2a..9a51e7efa 100644 --- a/test/scenarios/tools/virtual-filesystem/python/main.py +++ b/test/scenarios/tools/virtual-filesystem/python/main.py @@ -80,7 +80,7 @@ async def main(): print(f"\n[{path}]") print(content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts b/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts index 0a6f0ffd1..4f7dadfd6 100644 --- a/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts +++ b/test/scenarios/tools/virtual-filesystem/typescript/src/index.ts @@ -74,7 +74,7 @@ async function main() { console.log(content); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/transport/reconnect/README.md b/test/scenarios/transport/reconnect/README.md index 4ae3c22d2..c2ed0d2fa 100644 --- a/test/scenarios/transport/reconnect/README.md +++ b/test/scenarios/transport/reconnect/README.md @@ -7,8 +7,8 @@ Tests that a **pre-running** `copilot` TCP server correctly handles **multiple s │ Your App │ ─────────────────▶ │ Copilot CLI │ │ (SDK) │ ◀───────────────── │ (TCP server) │ └─────────────┘ └──────────────┘ - Session 1: create → send → destroy - Session 2: create → send → destroy + Session 1: create → send → disconnect + Session 2: create → send → disconnect ``` ## What This Tests diff --git a/test/scenarios/transport/reconnect/csharp/Program.cs b/test/scenarios/transport/reconnect/csharp/Program.cs index a93ed8a71..80dc482da 100644 --- a/test/scenarios/transport/reconnect/csharp/Program.cs +++ b/test/scenarios/transport/reconnect/csharp/Program.cs @@ -28,7 +28,7 @@ Console.Error.WriteLine("No response content received for session 1"); Environment.Exit(1); } - Console.WriteLine("Session 1 destroyed\n"); + Console.WriteLine("Session 1 disconnected\n"); // Second session — tests that the server accepts new sessions Console.WriteLine("--- Session 2 ---"); @@ -51,7 +51,7 @@ Console.Error.WriteLine("No response content received for session 2"); Environment.Exit(1); } - Console.WriteLine("Session 2 destroyed"); + Console.WriteLine("Session 2 disconnected"); Console.WriteLine("\nReconnect test passed — both sessions completed successfully"); } diff --git a/test/scenarios/transport/reconnect/go/main.go b/test/scenarios/transport/reconnect/go/main.go index 27f6c1592..493e9d258 100644 --- a/test/scenarios/transport/reconnect/go/main.go +++ b/test/scenarios/transport/reconnect/go/main.go @@ -43,8 +43,8 @@ func main() { log.Fatal("No response content received for session 1") } - session1.Destroy() - fmt.Println("Session 1 destroyed") + session1.Disconnect() + fmt.Println("Session 1 disconnected") fmt.Println() // Session 2 — tests that the server accepts new sessions @@ -69,8 +69,8 @@ func main() { log.Fatal("No response content received for session 2") } - session2.Destroy() - fmt.Println("Session 2 destroyed") + session2.Disconnect() + fmt.Println("Session 2 disconnected") 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 index e8aecea50..1b82b1096 100644 --- a/test/scenarios/transport/reconnect/python/main.py +++ b/test/scenarios/transport/reconnect/python/main.py @@ -24,8 +24,8 @@ async def main(): print("No response content received for session 1", file=sys.stderr) sys.exit(1) - await session1.destroy() - print("Session 1 destroyed\n") + await session1.disconnect() + print("Session 1 disconnected\n") # Second session — tests that the server accepts new sessions print("--- Session 2 ---") @@ -41,8 +41,8 @@ async def main(): print("No response content received for session 2", file=sys.stderr) sys.exit(1) - await session2.destroy() - print("Session 2 destroyed") + await session2.disconnect() + print("Session 2 disconnected") print("\nReconnect test passed — both sessions completed successfully") finally: diff --git a/test/scenarios/transport/reconnect/typescript/src/index.ts b/test/scenarios/transport/reconnect/typescript/src/index.ts index 57bac483d..ca28df94b 100644 --- a/test/scenarios/transport/reconnect/typescript/src/index.ts +++ b/test/scenarios/transport/reconnect/typescript/src/index.ts @@ -21,8 +21,8 @@ async function main() { process.exit(1); } - await session1.destroy(); - console.log("Session 1 destroyed\n"); + await session1.disconnect(); + console.log("Session 1 disconnected\n"); // Second session — tests that the server accepts new sessions console.log("--- Session 2 ---"); @@ -39,8 +39,8 @@ async function main() { process.exit(1); } - await session2.destroy(); - console.log("Session 2 destroyed"); + await session2.disconnect(); + console.log("Session 2 disconnected"); console.log("\nReconnect test passed — both sessions completed successfully"); } finally { diff --git a/test/scenarios/transport/stdio/csharp/Program.cs b/test/scenarios/transport/stdio/csharp/Program.cs index 50505b776..cb67c903c 100644 --- a/test/scenarios/transport/stdio/csharp/Program.cs +++ b/test/scenarios/transport/stdio/csharp/Program.cs @@ -3,7 +3,7 @@ using var client = new CopilotClient(new CopilotClientOptions { CliPath = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH"), - GithubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), + GitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN"), }); await client.StartAsync(); diff --git a/test/scenarios/transport/stdio/go/main.go b/test/scenarios/transport/stdio/go/main.go index 5543f6b4d..b8902fd99 100644 --- a/test/scenarios/transport/stdio/go/main.go +++ b/test/scenarios/transport/stdio/go/main.go @@ -12,7 +12,7 @@ import ( func main() { // Go SDK auto-reads COPILOT_CLI_PATH from env client := copilot.NewClient(&copilot.ClientOptions{ - GithubToken: os.Getenv("GITHUB_TOKEN"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), }) ctx := context.Background() @@ -27,7 +27,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What is the capital of France?", diff --git a/test/scenarios/transport/stdio/python/main.py b/test/scenarios/transport/stdio/python/main.py index 138bb5646..d1441361f 100644 --- a/test/scenarios/transport/stdio/python/main.py +++ b/test/scenarios/transport/stdio/python/main.py @@ -19,7 +19,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/transport/stdio/typescript/src/index.ts b/test/scenarios/transport/stdio/typescript/src/index.ts index 989a0b9a6..bee246f64 100644 --- a/test/scenarios/transport/stdio/typescript/src/index.ts +++ b/test/scenarios/transport/stdio/typescript/src/index.ts @@ -17,7 +17,7 @@ async function main() { console.log(response.data.content); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/scenarios/transport/tcp/go/main.go b/test/scenarios/transport/tcp/go/main.go index 9a0b1be4e..8be7dd605 100644 --- a/test/scenarios/transport/tcp/go/main.go +++ b/test/scenarios/transport/tcp/go/main.go @@ -31,7 +31,7 @@ func main() { if err != nil { log.Fatal(err) } - defer session.Destroy() + defer session.Disconnect() response, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: "What is the capital of France?", diff --git a/test/scenarios/transport/tcp/python/main.py b/test/scenarios/transport/tcp/python/main.py index 05aaa9270..c407d4fea 100644 --- a/test/scenarios/transport/tcp/python/main.py +++ b/test/scenarios/transport/tcp/python/main.py @@ -18,7 +18,7 @@ async def main(): if response: print(response.data.content) - await session.destroy() + await session.disconnect() finally: await client.stop() diff --git a/test/scenarios/transport/tcp/typescript/src/index.ts b/test/scenarios/transport/tcp/typescript/src/index.ts index 139e47a86..29a19dd10 100644 --- a/test/scenarios/transport/tcp/typescript/src/index.ts +++ b/test/scenarios/transport/tcp/typescript/src/index.ts @@ -19,7 +19,7 @@ async function main() { process.exit(1); } - await session.destroy(); + await session.disconnect(); } finally { await client.stop(); } diff --git a/test/snapshots/agent_and_compact_rpc/should_compact_session_history_after_messages.yaml b/test/snapshots/agent_and_compact_rpc/should_compact_session_history_after_messages.yaml new file mode 100644 index 000000000..fa5cf614a --- /dev/null +++ b/test/snapshots/agent_and_compact_rpc/should_compact_session_history_after_messages.yaml @@ -0,0 +1,80 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 2+2? + - role: assistant + content: 2+2 = 4 + - role: user + content: ${compaction_prompt} + - role: assistant + content: >- + + + The user asked a simple arithmetic question ("What is 2+2?") which was answered directly (4). No technical + work, code changes, or file modifications were requested or performed. This appears to be a minimal test or + verification exchange before any substantive work begins. + + + + + + + 1. The user asked "What is 2+2?" + - Provided the answer: 4 + - No follow-up actions were needed + + + + + + No work has been performed. No files were created, modified, or deleted. This was purely an informational + exchange with no code changes or system modifications. + + + Current state: + + - Working directory: ${workdir} + + - Not a git repository + + - No files have been accessed or modified + + - No todos or plan have been created + + + + + + + - Environment is Windows (Windows_NT), requiring backslash path separators + + - Session workspace available for artifacts (e.g., plan.md) + + - SQL database available but not yet initialized (no tables created) + + - Available tools: git, curl, gh + + - No technical work has been performed yet, so no technical details have been discovered + + + + + + + No files have been accessed or modified during this conversation. + + + + + + + No work is currently in progress. Awaiting user's next request for any substantive task. + + + + + Simple arithmetic question answered diff --git a/test/snapshots/ask_user/should_invoke_user_input_handler_when_model_uses_ask_user_tool.yaml b/test/snapshots/ask_user/should_invoke_user_input_handler_when_model_uses_ask_user_tool.yaml index beb7a5848..4549b99dc 100644 --- a/test/snapshots/ask_user/should_invoke_user_input_handler_when_model_uses_ask_user_tool.yaml +++ b/test/snapshots/ask_user/should_invoke_user_input_handler_when_model_uses_ask_user_tool.yaml @@ -18,4 +18,4 @@ conversations: tool_call_id: toolcall_0 content: "User selected: Option A" - role: assistant - content: You selected **Option A**. How would you like to proceed? + content: You selected **Option A**. What would you like me to do next? diff --git a/test/snapshots/builtin_tools/should_capture_exit_code_in_output.yaml b/test/snapshots/builtin_tools/should_capture_exit_code_in_output.yaml new file mode 100644 index 000000000..3285ae23c --- /dev/null +++ b/test/snapshots/builtin_tools/should_capture_exit_code_in_output.yaml @@ -0,0 +1,55 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo hello && echo world'. Tell me the exact output. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running echo commands"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"echo hello && echo world","description":"Run echo hello && echo world"}' + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo hello && echo world'. Tell me the exact output. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running echo commands"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"echo hello && echo world","description":"Run echo hello && echo world"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: |- + hello + world + + - role: assistant + content: |- + The exact output is: + ``` + hello + world + ``` diff --git a/test/snapshots/builtin_tools/should_capture_stderr_output.yaml b/test/snapshots/builtin_tools/should_capture_stderr_output.yaml new file mode 100644 index 000000000..32db145ca --- /dev/null +++ b/test/snapshots/builtin_tools/should_capture_stderr_output.yaml @@ -0,0 +1,23 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo error_msg >&2; echo ok' and tell me what stderr said. Reply with just the stderr content. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"echo error_msg >&2; echo ok","description":"Run command with stderr output"}' + - role: tool + tool_call_id: toolcall_0 + content: |- + error_msg + ok + + - role: assistant + content: error_msg diff --git a/test/snapshots/builtin_tools/should_create_a_new_file.yaml b/test/snapshots/builtin_tools/should_create_a_new_file.yaml new file mode 100644 index 000000000..fe75810a1 --- /dev/null +++ b/test/snapshots/builtin_tools/should_create_a_new_file.yaml @@ -0,0 +1,57 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Create a file called 'new_file.txt' with the content 'Created by test'. Then read it back to confirm. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Creating new file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: create + arguments: '{"path":"${workdir}/new_file.txt","file_text":"Created by test"}' + - messages: + - role: system + content: ${system} + - role: user + content: Create a file called 'new_file.txt' with the content 'Created by test'. Then read it back to confirm. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Creating new file"}' + - id: toolcall_1 + type: function + function: + name: create + arguments: '{"path":"${workdir}/new_file.txt","file_text":"Created by test"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: Created file ${workdir}/new_file.txt with 15 characters + - role: assistant + tool_calls: + - id: toolcall_2 + type: function + function: + name: view + arguments: '{"path":"${workdir}/new_file.txt"}' + - role: tool + tool_call_id: toolcall_2 + content: 1. Created by test + - role: assistant + content: File created and confirmed! The file 'new_file.txt' contains "Created by test" as expected. diff --git a/test/snapshots/builtin_tools/should_edit_a_file_successfully.yaml b/test/snapshots/builtin_tools/should_edit_a_file_successfully.yaml new file mode 100644 index 000000000..b2d3d9451 --- /dev/null +++ b/test/snapshots/builtin_tools/should_edit_a_file_successfully.yaml @@ -0,0 +1,65 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: "Edit the file 'edit_me.txt': replace 'Hello World' with 'Hi Universe'. Then read it back and tell me its + contents." + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Editing file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: edit + arguments: '{"path":"${workdir}/edit_me.txt","old_str":"Hello World","new_str":"Hi Universe"}' + - messages: + - role: system + content: ${system} + - role: user + content: "Edit the file 'edit_me.txt': replace 'Hello World' with 'Hi Universe'. Then read it back and tell me its + contents." + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Editing file"}' + - id: toolcall_1 + type: function + function: + name: edit + arguments: '{"path":"${workdir}/edit_me.txt","old_str":"Hello World","new_str":"Hi Universe"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: File ${workdir}/edit_me.txt updated with changes. + - role: assistant + tool_calls: + - id: toolcall_2 + type: function + function: + name: view + arguments: '{"path":"${workdir}/edit_me.txt"}' + - role: tool + tool_call_id: toolcall_2 + content: |- + 1. Hi Universe + 2. Goodbye World + 3. + - role: assistant + content: |- + The file now contains: + 1. Hi Universe + 2. Goodbye World diff --git a/test/snapshots/builtin_tools/should_find_files_by_pattern.yaml b/test/snapshots/builtin_tools/should_find_files_by_pattern.yaml new file mode 100644 index 000000000..0fa78ce4f --- /dev/null +++ b/test/snapshots/builtin_tools/should_find_files_by_pattern.yaml @@ -0,0 +1,52 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Find all .ts files in this directory (recursively). List the filenames you found. + - role: assistant + content: I'll search for all TypeScript files recursively in the current directory. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Finding TypeScript files"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: glob + arguments: '{"pattern":"**/*.ts"}' + - messages: + - role: system + content: ${system} + - role: user + content: Find all .ts files in this directory (recursively). List the filenames you found. + - role: assistant + content: I'll search for all TypeScript files recursively in the current directory. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Finding TypeScript files"}' + - id: toolcall_1 + type: function + function: + name: glob + arguments: '{"pattern":"**/*.ts"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: ./src/index.ts + - role: assistant + content: |- + Found **1 TypeScript file**: + - `src/index.ts` diff --git a/test/snapshots/builtin_tools/should_handle_nonexistent_file_gracefully.yaml b/test/snapshots/builtin_tools/should_handle_nonexistent_file_gracefully.yaml new file mode 100644 index 000000000..2e5e4368c --- /dev/null +++ b/test/snapshots/builtin_tools/should_handle_nonexistent_file_gracefully.yaml @@ -0,0 +1,47 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Try to read the file 'does_not_exist.txt'. If it doesn't exist, say 'FILE_NOT_FOUND'. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/does_not_exist.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Try to read the file 'does_not_exist.txt'. If it doesn't exist, say 'FILE_NOT_FOUND'. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/does_not_exist.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: Path ${workdir}/does_not_exist.txt does not exist. Please provide a valid path. + - role: assistant + content: FILE_NOT_FOUND diff --git a/test/snapshots/builtin_tools/should_read_file_with_line_range.yaml b/test/snapshots/builtin_tools/should_read_file_with_line_range.yaml new file mode 100644 index 000000000..382da0908 --- /dev/null +++ b/test/snapshots/builtin_tools/should_read_file_with_line_range.yaml @@ -0,0 +1,54 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read lines 2 through 4 of the file 'lines.txt' in this directory. Tell me what those lines contain. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading specific file lines"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/lines.txt","view_range":[2,4]}' + - messages: + - role: system + content: ${system} + - role: user + content: Read lines 2 through 4 of the file 'lines.txt' in this directory. Tell me what those lines contain. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading specific file lines"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/lines.txt","view_range":[2,4]}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: |- + 2. line2 + 3. line3 + 4. line4 + - role: assistant + content: |- + Lines 2 through 4 contain: + - Line 2: "line2" + - Line 3: "line3" + - Line 4: "line4" diff --git a/test/snapshots/builtin_tools/should_search_for_patterns_in_files.yaml b/test/snapshots/builtin_tools/should_search_for_patterns_in_files.yaml new file mode 100644 index 000000000..89af253b5 --- /dev/null +++ b/test/snapshots/builtin_tools/should_search_for_patterns_in_files.yaml @@ -0,0 +1,52 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Search for lines starting with 'ap' in the file 'data.txt'. Tell me which lines matched. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Searching file for pattern"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: grep + arguments: '{"pattern":"^ap","path":"${workdir}/data.txt","output_mode":"content","-n":true}' + - messages: + - role: system + content: ${system} + - role: user + content: Search for lines starting with 'ap' in the file 'data.txt'. Tell me which lines matched. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Searching file for pattern"}' + - id: toolcall_1 + type: function + function: + name: grep + arguments: '{"pattern":"^ap","path":"${workdir}/data.txt","output_mode":"content","-n":true}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: |- + ${workdir}/data.txt:1:apple + ${workdir}/data.txt:3:apricot + - role: assistant + content: |- + Two lines matched: + - Line 1: `apple` + - Line 3: `apricot` diff --git a/test/snapshots/client_lifecycle/should_emit_session_lifecycle_events.yaml b/test/snapshots/client_lifecycle/should_emit_session_lifecycle_events.yaml new file mode 100644 index 000000000..beb8b443d --- /dev/null +++ b/test/snapshots/client_lifecycle/should_emit_session_lifecycle_events.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hello + - role: assistant + content: Hello! I'm GitHub Copilot CLI, ready to help you with software engineering tasks. How can I assist you today? diff --git a/test/snapshots/client_lifecycle/should_return_last_session_id_after_sending_a_message.yaml b/test/snapshots/client_lifecycle/should_return_last_session_id_after_sending_a_message.yaml new file mode 100644 index 000000000..3b9da534c --- /dev/null +++ b/test/snapshots/client_lifecycle/should_return_last_session_id_after_sending_a_message.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hello + - role: assistant + content: Hello! I'm GitHub Copilot CLI, ready to help with your software engineering tasks. diff --git a/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml b/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml index a33904cfb..ae44af7c6 100644 --- a/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml +++ b/test/snapshots/compaction/should_trigger_compaction_with_low_threshold_and_emit_events.yaml @@ -5,514 +5,661 @@ conversations: - role: system content: ${system} - role: user - content: Tell me a long story about a dragon. Be very detailed. + content: Tell me a story about a dragon. Be detailed. - role: assistant content: >- - I'm a terminal assistant designed to help with software engineering tasks like writing code, debugging, and - managing repositories. While I can tell you a story, it's a bit outside my main purpose! + # The Last Ember of Thornkeep - Here's a brief tale: In the volcanic peaks of Mount Ashencrag lived Theraxis, an ancient crimson dragon whose - scales shimmered like molten copper. For centuries, she guarded a library of stone tablets inscribed by the - first civilizations, breathing gentle warmth to preserve them through harsh winters. When a young scholar - stumbled into her lair seeking forbidden knowledge, Theraxis didn't devour him—instead, she challenged him to - a game of riddles, each answer unlocking one tablet's secrets, teaching him that true wisdom required patience - and respect for those who came before. + In the craggy peaks of the Ashfall Mountains, where volcanic vents still breathed smoke into perpetual + twilight, there lived a dragon named Kaedrith. Her scales shimmered like molten copper, each one edged in + obsidian black, and her eyes held the amber glow of dying stars. - Is there a coding project or technical task I can help you with instead? + Kaedrith was ancient—so old that she had witnessed the rise and fall of three kingdoms in the valley below. + She had once been fearsome, a terror that sent knights fleeing and villages scrambling to offer tribute. But + those days had faded like morning mist. Now she spent her centuries in solitude, tending to her greatest + treasure: not gold or jewels, but a garden of fire lilies that grew only in dragon flame. + + + One winter evening, as ash-snow drifted through the mountain passes, a child appeared at the mouth of her + cave. A girl, no more than ten, with eyes too weary for her age and hands wrapped in frost-bitten cloth. She + didn't scream. She didn't run. She simply stood there, shivering, and said: "They told me you eat people. I + need to not be a person anymore." + + + Kaedrith, who had lived long enough to understand the weight of such words, curled her massive form around the + child like a shield of scales and warmth. "What are you running from?" she rumbled, her voice like distant + thunder. + + + "A king who thinks children are coin," the girl whispered. "He sells us to factories in the dark cities." + + + The dragon's eyes blazed brighter. Deep in her chest, something that had been dormant for centuries + stirred—the ancient rage that had once made mountains tremble. But she had learned patience through time's + long lessons. + + + Instead of descending upon the valley in fury, Kaedrith taught the girl. She showed her how to tend the fire + lilies, whose petals held healing properties. She shared stories of the old world, when dragons and humans had + been allies, not enemies. And slowly, other children found their way up the mountain—runaways, orphans, the + forgotten. + + + Kaedrith's cave became a sanctuary, warmed by dragon fire and filled with laughter that echoed off stone + walls. And when the king finally sent his soldiers to drag the children back, they found not helpless + refugees, but a dragon fully awakened—with a family worth protecting. + + + The battle lasted seven minutes. The soldiers never returned. + + + The king, wisely, never sent more. - role: user content: Continue the story with more details about the dragon's castle. - role: user + content: ${compaction_prompt} + - role: assistant content: >- - Please create a detailed summary of the conversation so far. The history is being compacted so moving forward, - all conversation history will be removed and you'll only have this summary to work from. Be sure to make note - of the user's explicit requests, your actions, and any key technical details. + + The user requested creative storytelling content: first a detailed story about a dragon, then a continuation + focusing on the dragon's castle. This is a pure creative writing task with no technical work, code changes, or + file modifications involved. - The summary should include the following parts: + - 1. - high-level summary of goals and approach - 2. - chronological analysis of the conversation + - 3. - changes made, current state, and any issues encountered + 1. The user asked for a detailed story about a dragon + - Composed "The Last Ember of Thornkeep" - a story about Kaedrith, an ancient copper-scaled dragon in the Ashfall Mountains + - Story focused on the dragon creating a sanctuary for runaway children fleeing a tyrannical king + - Included detailed descriptions of the dragon's appearance, personality, and transformation from solitary guardian to protector of refugees - 4. - key concepts, decisions, and quirks discovered + 2. The user asked to continue the story with more details about the dragon's castle + - Request received just before checkpoint compaction was triggered + - No response provided yet to this continuation request + - 5. - files central to the work and why they matter - 6. - pending tasks and planned actions + - 7. - 2-6 word description of the main work done + Creative content delivered: + - Completed initial dragon story (approximately 400 words) - --- + - Story established: setting (Ashfall Mountains), protagonist (Kaedrith the dragon), conflict (children + fleeing exploitation), resolution (dragon creates sanctuary and defeats the king's soldiers) - ## Section Guidelines + Work in progress: + - Continuation about the dragon's castle/dwelling has been requested but not yet written - ### Overview + - Provide a concise summary (2-3 sentences) capturing the user's goals, intent, and expectations. Describe your - overall approach and strategy for addressing their needs, and note any constraints or requirements that were - established. + - This section should give a reader immediate clarity on what this conversation is about and how you're tackling - it. + - This is purely creative writing work - no code, files, or technical systems involved + - No tools were needed or used for this storytelling task - ### History + - User preference appears to be for detailed, narrative-driven fantasy content with emotional depth + - Capture the narrative arc of the conversation—what was asked for, what was done, and how the work evolved. - Structure this around the user's requests: each request becomes an entry with the actions you took nested - underneath, in chronological order. - Note any major pivots or changes in direction, and include outcomes where relevant—especially for debugging or - when something didn't go as expected. Focus on meaningful actions, not granular details of every exchange. + + None. This conversation involves only creative writing responses with no file system interaction. - ### Work Done + - Document the concrete work completed during this conversation. This section should enable someone to pick up - exactly where you left off. Include: + + Immediate next step: - - Files created, modified, or deleted + - Continue the dragon story with detailed descriptions of Kaedrith's castle/cave sanctuary, expanding on the + world-building and the community that has formed there - - Tasks completed and their outcomes + - - What you were most recently working on - - Current state: what works, what doesn't, what's untested + Dragon storytelling creative writing + - messages: + - role: system + content: ${system} + - role: user + content: Tell me a story about a dragon. Be detailed. + - role: assistant + content: >- + # The Last Ember of Thornkeep - ### Technical Details + In the craggy peaks of the Ashfall Mountains, where volcanic vents still breathed smoke into perpetual + twilight, there lived a dragon named Kaedrith. Her scales shimmered like molten copper, each one edged in + obsidian black, and her eyes held the amber glow of dying stars. - Capture the technical knowledge that would be painful to rediscover. Think of this as a knowledge base for - your future self—anything that took effort to learn belongs here. This includes: + Kaedrith was ancient—so old that she had witnessed the rise and fall of three kingdoms in the valley below. + She had once been fearsome, a terror that sent knights fleeing and villages scrambling to offer tribute. But + those days had faded like morning mist. Now she spent her centuries in solitude, tending to her greatest + treasure: not gold or jewels, but a garden of fire lilies that grew only in dragon flame. - - Key concepts and architectural decisions (with rationale) + One winter evening, as ash-snow drifted through the mountain passes, a child appeared at the mouth of her + cave. A girl, no more than ten, with eyes too weary for her age and hands wrapped in frost-bitten cloth. She + didn't scream. She didn't run. She simply stood there, shivering, and said: "They told me you eat people. I + need to not be a person anymore." - - Issues encountered and how they were resolved - - Quirks, gotchas, or non-obvious behaviors + Kaedrith, who had lived long enough to understand the weight of such words, curled her massive form around the + child like a shield of scales and warmth. "What are you running from?" she rumbled, her voice like distant + thunder. - - Dependencies, versions, or environment details that matter - - Workarounds or constraints you discovered + "A king who thinks children are coin," the girl whispered. "He sells us to factories in the dark cities." - Also make note of any questions that remain unanswered or assumptions that you aren't fully confident about. + The dragon's eyes blazed brighter. Deep in her chest, something that had been dormant for centuries + stirred—the ancient rage that had once made mountains tremble. But she had learned patience through time's + long lessons. - ### Important Files + Instead of descending upon the valley in fury, Kaedrith taught the girl. She showed her how to tend the fire + lilies, whose petals held healing properties. She shared stories of the old world, when dragons and humans had + been allies, not enemies. And slowly, other children found their way up the mountain—runaways, orphans, the + forgotten. - List the files most central to the task, prioritizing those you've actively worked on over files you merely - viewed. This isn't an exhaustive inventory—it's a curated list of what matters most for continuing the work. - For each file, include: + Kaedrith's cave became a sanctuary, warmed by dragon fire and filled with laughter that echoed off stone + walls. And when the king finally sent his soldiers to drag the children back, they found not helpless + refugees, but a dragon fully awakened—with a family worth protecting. - - The file name + The battle lasted seven minutes. The soldiers never returned. - - Why it's important to the project - - Summary of changes made (if any) + The king, wisely, never sent more. + - role: user + content: Continue the story with more details about the dragon's castle. + - role: assistant + content: >- + # The Sanctuary of Scales + + + What had begun as a simple cave transformed over the years into something far more magnificent. Kaedrith's + lair sprawled through the mountain's heart like a labyrinth of wonders, carved not by tools but by dragon fire + and patient claws over centuries. + + + The **Grand Entrance** was a natural cathedral of stone, its ceiling lost in shadow sixty feet above. + Stalactites glittered with embedded crystals that caught the light of ever-burning braziers—gifts from + Kaedrith's own flame, designed never to extinguish. The children had painted murals across the walls: dragons + soaring through skies, flowers blooming in impossible colors, and portraits of themselves as heroes in their + own stories. + + + Beyond lay the **Chamber of Wings**, where Kaedrith slept coiled around a natural hot spring. Mineral-rich + water bubbled up from volcanic depths, filling the air with steam that smelled of minerals and magic. The + children had built sleeping lofts into the chamber walls using salvaged timber and rope, each one + customized—some with hanging gardens of cave moss, others with collections of interesting rocks, and one + ambitious structure that resembled a ship's crow's nest. + - - Key line numbers or sections to reference + The **Garden of Eternal Flame** was Kaedrith's pride. This vast cavern had openings in its ceiling that + created perfect conditions for her fire lilies. The flowers grew in spiral patterns, their petals shifting + between crimson, gold, and blue depending on the temperature of dragon breath used to nurture them. The + children learned to harvest them carefully, drying the petals to create medicines they traded with the one + village brave enough to maintain contact with the dragon's domain. - ### Next Steps + Down twisting corridors carved smooth by ancient lava flows, there was the **Library of Scales**. Here, + Kaedrith had scratched histories into the walls themselves—accounts of the old world in draconic script that + glowed faintly in darkness. The children couldn't read it yet, but she was teaching them, letter by letter. + They'd also added their own contribution: books salvaged from abandoned places, stacked on stone shelves, + covering everything from fairy tales to blacksmithing manuals. - If there's pending work, describe what you were actively working on when compaction occurred. List remaining - tasks, outline your planned approach, and flag any blockers or open questions. + The **Treasury**, ironically, held little gold. Kaedrith had long ago traded most of her hoard for things more + useful—seeds, tools, warm blankets. What remained was eclectic: a crown she'd taken from a tyrant king three + centuries ago, now used as a planter for herbs; scattered gems that caught firelight; ancient armor pieces + repurposed as wall decorations; and most precious of all, a collection of small objects the children had given + her—crude clay figures, woven bracelets, a wooden carving of a dragon that looked more like a lizard with + wings. - If you've finished all requested work, you can simply note that no next steps are needed. + The mountain fortress also contained practical spaces: the **Forge Room**, where heat from volcanic vents + allowed them to work metal; the **Larder**, naturally cold passages that preserved food; and the **Rain + Catcher**, an ingenious system of channels carved into the mountain's exterior that funneled fresh water into + underground cisterns. - ### Checkpoint Title + But the heart of the sanctuary was the **Gathering Hall**, a wide chamber where stalactites and stalagmites + had been carefully preserved to create natural pillars. Here they shared meals around a great stone table that + Kaedrith had smoothed to perfect flatness. Here stories were told. Here plans were made. Here, on quiet + evenings, the dragon would curl at one end of the hall while children used her tail as a climbing structure + and read aloud from books, their voices echoing off ancient stone. - Provide a concise 2-6 word title capturing the essence of what was accomplished in this work segment. This - title will be used to identify this checkpoint when reviewing session history. Examples: - - "Implementing user authentication" + Travelers who glimpsed the mountain from afar spoke of strange lights dancing in its peaks at night—the glow + of dragon fire and human laughter, weaving together something the world hadn't seen in an age: a true + alliance, a family forged not by blood but by choice, protected by scale and claw and the fierce love of an + ancient creature who had finally found her purpose. + + + And in the valley below, the cruel king's power waned, while whispered stories of the Dragon's Sanctuary + spread—a place where the lost could be found, where the small could become mighty, where an ember of the old + world still burned bright against the darkness. + - messages: + - role: system + content: ${system} + - role: user + content: >- + Some of the conversation history has been summarized to free up context. + - - "Fixing database connection bugs" + You were originally given instructions from a user over one or more turns. Here were the user messages: - - "Refactoring payment module" + - - "Adding unit tests for API" + Tell me a story about a dragon. Be detailed. + - --- + + Continue the story with more details about the dragon's castle. - ## Example + - Here is an example of the structure you should follow: + Here is a summary of the prior context: - + - [2-3 sentences describing the user's goals and your approach] + The user requested creative storytelling content: first a detailed story about a dragon, then a continuation + focusing on the dragon's castle. This is a pure creative writing task with no technical work, code changes, or + file modifications involved. + - 1. The user asked to [request] - - [action taken] - - [action taken] - - [outcome/result] + 1. The user asked for a detailed story about a dragon + - Composed "The Last Ember of Thornkeep" - a story about Kaedrith, an ancient copper-scaled dragon in the Ashfall Mountains + - Story focused on the dragon creating a sanctuary for runaway children fleeing a tyrannical king + - Included detailed descriptions of the dragon's appearance, personality, and transformation from solitary guardian to protector of refugees - 2. The user asked to [request] - - [action taken] - - [action taken] - - [outcome/result] + 2. The user asked to continue the story with more details about the dragon's castle + - Request received just before checkpoint compaction was triggered + - No response provided yet to this continuation request - - Files updated: + - - [file]: [what changed] + Creative content delivered: + - Completed initial dragon story (approximately 400 words) - Work completed: + - Story established: setting (Ashfall Mountains), protagonist (Kaedrith the dragon), conflict (children + fleeing exploitation), resolution (dragon creates sanctuary and defeats the king's soldiers) - - [x] [Task] - - [x] [Task] + Work in progress: - - [ ] [Task in progress or incomplete] + - Continuation about the dragon's castle/dwelling has been requested but not yet written - - - [Key technical concept or decision] + - - [Issue encountered and how it was resolved] + - This is purely creative writing work - no code, files, or technical systems involved - - [Non-obvious behavior or quirk discovered] + - No tools were needed or used for this storytelling task - - [Unresolved question or uncertain area] + - User preference appears to be for detailed, narrative-driven fantasy content with emotional depth + - - [file1] - - [Why it matters] - - [Changes made, if any] - - [Key line numbers] - - [file2] - - [Why it matters] - - [Changes made, if any] - - [Key line numbers] + None. This conversation involves only creative writing responses with no file system interaction. + - - Remaining work: + - - [Task] + Immediate next step: - - [Task] + - Continue the dragon story with detailed descriptions of Kaedrith's castle/cave sanctuary, expanding on the + world-building and the community that has formed there + - Immediate next steps: - - [Action to take] + Dragon storytelling creative writing - - [Action to take] + + - role: assistant + content: >- + # The Sanctuary of Scales - + What had begun as a simple cave transformed over the years into something far more magnificent. Kaedrith's + lair sprawled through the mountain's heart like a labyrinth of wonders, carved not by tools but by dragon fire + and patient claws over centuries. - Concise 2-6 word description of this checkpoint - + The **Grand Entrance** was a natural cathedral of stone, its ceiling lost in shadow sixty feet above. + Stalactites glittered with embedded crystals that caught the light of ever-burning braziers—gifts from + Kaedrith's own flame, designed never to extinguish. The children had painted murals across the walls: dragons + soaring through skies, flowers blooming in impossible colors, and portraits of themselves as heroes in their + own stories. - --- + Beyond lay the **Chamber of Wings**, where Kaedrith slept coiled around a natural hot spring. Mineral-rich + water bubbled up from volcanic depths, filling the air with steam that smelled of minerals and magic. The + children had built sleeping lofts into the chamber walls using salvaged timber and rope, each one + customized—some with hanging gardens of cave moss, others with collections of interesting rocks, and one + ambitious structure that resembled a ship's crow's nest. - Please write the summary now, following the structure and guidelines above. Be concise where possible, but - don't sacrifice important context for brevity. - - role: assistant - content: >- - + The **Garden of Eternal Flame** was Kaedrith's pride. This vast cavern had openings in its ceiling that + created perfect conditions for her fire lilies. The flowers grew in spiral patterns, their petals shifting + between crimson, gold, and blue depending on the temperature of dragon breath used to nurture them. The + children learned to harvest them carefully, drying the petals to create medicines they traded with the one + village brave enough to maintain contact with the dragon's domain. - The user has made non-technical requests asking for creative storytelling content (a detailed dragon story and - details about the dragon's castle). No software engineering work has been requested or performed. As a - terminal assistant focused on coding and development tasks, I provided a brief story but redirected toward - technical assistance. - + Down twisting corridors carved smooth by ancient lava flows, there was the **Library of Scales**. Here, + Kaedrith had scratched histories into the walls themselves—accounts of the old world in draconic script that + glowed faintly in darkness. The children couldn't read it yet, but she was teaching them, letter by letter. + They'd also added their own contribution: books salvaged from abandoned places, stacked on stone shelves, + covering everything from fairy tales to blacksmithing manuals. - + The **Treasury**, ironically, held little gold. Kaedrith had long ago traded most of her hoard for things more + useful—seeds, tools, warm blankets. What remained was eclectic: a crown she'd taken from a tyrant king three + centuries ago, now used as a planter for herbs; scattered gems that caught firelight; ancient armor pieces + repurposed as wall decorations; and most precious of all, a collection of small objects the children had given + her—crude clay figures, woven bracelets, a wooden carving of a dragon that looked more like a lizard with + wings. - 1. The user asked for a long, detailed story about a dragon - - I explained my purpose as a software engineering assistant - - Provided a brief story about Theraxis, a dragon guarding ancient tablets - - Redirected to offer technical/coding assistance - 2. The user asked to continue the story with more details about the dragon's castle, then requested a - conversation summary for history compaction - - Creating this summary document - + The mountain fortress also contained practical spaces: the **Forge Room**, where heat from volcanic vents + allowed them to work metal; the **Larder**, naturally cold passages that preserved food; and the **Rain + Catcher**, an ingenious system of channels carved into the mountain's exterior that funneled fresh water into + underground cisterns. - + But the heart of the sanctuary was the **Gathering Hall**, a wide chamber where stalactites and stalagmites + had been carefully preserved to create natural pillars. Here they shared meals around a great stone table that + Kaedrith had smoothed to perfect flatness. Here stories were told. Here plans were made. Here, on quiet + evenings, the dragon would curl at one end of the hall while children used her tail as a climbing structure + and read aloud from books, their voices echoing off ancient stone. - No technical work has been performed. No files have been created, modified, or viewed. No code has been - written. No repositories have been explored or modified. + Travelers who glimpsed the mountain from afar spoke of strange lights dancing in its peaks at night—the glow + of dragon fire and human laughter, weaving together something the world hadn't seen in an age: a true + alliance, a family forged not by blood but by choice, protected by scale and claw and the fierce love of an + ancient creature who had finally found her purpose. - Current state: The conversation has consisted entirely of non-technical storytelling requests with no software - engineering tasks initiated. - + And in the valley below, the cruel king's power waned, while whispered stories of the Dragon's Sanctuary + spread—a place where the lost could be found, where the small could become mighty, where an ember of the old + world still burned bright against the darkness. + - role: user + content: Now describe the dragon's treasure in great detail. + - role: assistant + content: >- + # The Treasury of Kaedrith - + Most who spoke of dragons imagined mountains of gold coins, rivers of rubies, chambers so full of wealth you + could swim through precious metals. Kaedrith's treasury told a different story—one of transformation, + sacrifice, and the strange alchemy that turns cold metal into warm memory. - No technical details to report. No coding tasks, debugging, repository work, or software engineering - activities have occurred. - + The chamber itself was circular, perhaps forty feet across, with walls of dark volcanic glass that reflected + firelight in fractured patterns. The ceiling formed a natural dome where selenite crystals grew in delicate + formations, casting soft lunar glows even when no flame burned. - + **The Practical Treasures** - No files are relevant to this conversation. No files have been accessed, created, or modified. - + Against the eastern wall stood three ancient chests of blackened oak, their iron bindings turned green with + age. These held what remained of traditional wealth—but repurposed. - + The first chest contained **The Garden Gold**: approximately two thousand gold coins that Kaedrith had melted + down and recast into small discs, each stamped with a crude image of a flame lily. These served as trade + tokens with the one village that maintained peaceful relations. Each disc could be exchanged for + supplies—grain, cloth, medicine, seeds. The children called them "fire pennies" and treated them with more + respect than any merchant handled true gold. - No technical work is pending. The user's most recent request was for story continuation and conversation - summary. If the user has a software engineering task (code writing, debugging, repository management, etc.), - that would be the appropriate next direction. - + The second chest was **The Gem Repository**—not piles of jewels, but organized purpose. Diamonds sorted by + size for cutting tools. Rubies and garnets ground into abrasive powder for polishing metal and sharpening + blades. Emeralds and sapphires kept whole, reserved for trade in emergencies. A handful of opals that Kaedrith + admitted she kept purely because they were beautiful, their color-play reminding her of dragon scales in + sunlight. - Non-technical storytelling requests - - messages: - - role: system - content: ${system} - - role: user - content: Tell me a long story about a dragon. Be very detailed. - - role: assistant - content: >- - I'm a terminal assistant designed to help with software engineering tasks like writing code, debugging, and - managing repositories. While I can tell you a story, it's a bit outside my main purpose! + The third chest held **The Silk Hoard**: bolts of fabric accumulated over centuries. Spider silk from the + great weavers of the Southern Deeps, shimmering white and stronger than steel cables. Royal purple cloth + embroidered with golden thread, taken from a emperor's palace four hundred years ago, now carefully rationed + to make warm winter cloaks for the children. Crimson velvet that had once been curtains in a cathedral. Rolls + of practical wool and linen she'd traded for. - Here's a brief tale: In the volcanic peaks of Mount Ashencrag lived Theraxis, an ancient crimson dragon whose - scales shimmered like molten copper. For centuries, she guarded a library of stone tablets inscribed by the - first civilizations, breathing gentle warmth to preserve them through harsh winters. When a young scholar - stumbled into her lair seeking forbidden knowledge, Theraxis didn't devour him—instead, she challenged him to - a game of riddles, each answer unlocking one tablet's secrets, teaching him that true wisdom required patience - and respect for those who came before. + **The Crown Garden** - Is there a coding project or technical task I can help you with instead? - - role: user - content: Continue the story with more details about the dragon's castle. - - role: assistant - content: >- - I appreciate your interest in the story, but I'm really designed to help with software engineering tasks—like - writing code, fixing bugs, running tests, or managing files and repositories. + Set upon a natural stone pedestal grew what the children called the Crown Garden. **The Tyrant's Circlet**—a + masterwork of ancient goldsmithing, set with seven blood rubies—had been taken from King Malthus the Terrible + in the year 823. Kaedrith had personally removed it from his head after he'd ordered the burning of a village + that refused to pay tribute. - If you'd like, I could help you with something like: + Now, three centuries later, soil filled its hollow center and medicinal herbs flourished there. Feverfew + spilled over its golden rim. Chamomile flowers nodded where rubies gleamed. Tiny sage plants grew between the + crown's points. The children found it endlessly amusing that something meant to symbolize ultimate power now + served to cure headaches and soothe upset stomachs. - - Creating a text-based dragon adventure game - - Building a story generator script + Beside it sat **The Bishop's Mitre**, also converted to a planter, growing mint and lemon balm. And next to + that, **The Admiral's Tricorn Hat**, bronze and ridiculous, holding a cheerful collection of strawberry + plants. - - Setting up a creative writing project structure - - Analyzing text files or documents + **The Armor Wall** - Would any of those technical tasks interest you? - - messages: - - role: system - content: ${system} - - role: user - content: >- - Some of the conversation history has been summarized to free up context. + The northern wall displayed pieces of armor, arranged not for vanity but as a timeline of human ambition and + folly. - You were originally given instructions from a user over one or more turns. Here were the user messages: + **The Silver Paladin's Breastplate** (circa 600) was beautiful—mirror-bright, etched with prayers in Old + Ecclesiast. The paladin had come to slay the dragon as a demonstration of faith. Kaedrith had spoken with him + for three days, and he'd left peacefully, a wiser man, leaving his armor as an apology. - - Tell me a long story about a dragon. Be very detailed. + **The Obsidian Gauntlets of the Void Knight** (circa 1102) were darker, crafted from volcanic glass and black + steel, radiating residual curses. Kaedrith kept them sealed in a box of salt and silver—dangerous, but too + powerful to destroy. A reminder that some treasures were better left untouched. - - + **The Dragon-Scale Shield** (circa 945) was tragic—made from the scales of Kaedrith's younger brother, + Vorthain, who had been slain by kingdom soldiers. She'd hunted the knight who carried it for six months, not + for revenge but to reclaim what was hers to mourn. The shield hung in a place of honor, sometimes draped with + flowers. - Continue the story with more details about the dragon's castle. - + **A Collection of Helmets**—twelve in all—ranged from primitive iron caps to elaborate jousting helms with + plumes and visors. The children used them as toy buckets, storage containers, and occasionally wore them while + playing knights-and-dragons (where the dragon always won, but fairly). - Here is a summary of the prior context: + **The Memory Hoard** - - + This section occupied the western wall, and it was here that Kaedrith spent most of her contemplative hours. + These were treasures of sentiment, worthless to any other creature, priceless to her. - The user has made non-technical requests asking for creative storytelling content (a detailed dragon story and - details about the dragon's castle). No software engineering work has been requested or performed. As a - terminal assistant focused on coding and development tasks, I provided a brief story but redirected toward - technical assistance. - + **Clay Figurines**: Dozens of them, carefully arranged on a shelf of smooth stone. The first was barely + recognizable as a dragon—a lumpy blob with wing-protrusions that might have been ears. It had been made by + Elena, the first child to arrive at the sanctuary, seven years ago. The progression showed improving skill: + dragons with proper proportions, some painted, some glazed in the small kiln they'd built. The newest + additions looked almost professional. - + **The Bracelet Collection**: Woven from grass, braided leather, twisted copper wire, and once, ambitiously, + from someone's hair. Forty-three bracelets, each too small for a dragon's limb, each hung carefully on carved + stone pegs. Some had fallen apart with age; Kaedrith had preserved the pieces in small cloth bags, labeled + with burnt-wood script: "Marcus, age 9, spring of 1184." - 1. The user asked for a long, detailed story about a dragon - - I explained my purpose as a software engineering assistant - - Provided a brief story about Theraxis, a dragon guarding ancient tablets - - Redirected to offer technical/coding assistance - 2. The user asked to continue the story with more details about the dragon's castle, then requested a - conversation summary for history compaction - - Creating this summary document - + **Wooden Carvings**: A menagerie of attempts. Dragon-lizards with too many legs. A remarkably good hawk. + Several abstract shapes that might have been anything. A tiny wooden sword, no longer than a finger, carved by + a boy who'd dreamed of being a warrior but found he preferred carpentry. - + **Letters and Drawings**: Stored in a fireproof iron case, hundreds of pieces of parchment, bark-paper, and + scraped leather. Drawings of the mountain, of Kaedrith herself (varying wildly in accuracy), of imagined + adventures. Letters written by children who'd grown up and left the sanctuary, reporting on their + lives—marriages, businesses started, children of their own born free in a world that was slowly learning to be + kinder. - No technical work has been performed. No files have been created, modified, or viewed. No code has been - written. No repositories have been explored or modified. + **The Peculiar Items** - Current state: The conversation has consisted entirely of non-technical storytelling requests with no software - engineering tasks initiated. - + Some treasures defied categorization: - + **The Eternal Candle**: A single white candle that had been burning for ninety-seven years, its flame never + wavering, never consuming the wax. Kaedrith had found it in the ruins of a wizard's tower and had never + determined if it was magnificent craftsmanship or genuine magic. Either way, it served as a nightlight in the + treasury. - No technical details to report. No coding tasks, debugging, repository work, or software engineering - activities have occurred. - + **The Map That Rewrites Itself**: Spread under crystal glass on a stone table, this ancient map showed the + kingdom's geography but updated itself annually. Mountains shifted. Rivers changed course. Villages appeared + and disappeared. Kaedrith used it to monitor the human world, to know where refugees might be fleeing from. - + **Three Dragon Eggs**: Smooth, dark, cold as stone. Her own children, who had never hatched. She'd laid them + two hundred years ago, and they had remained dormant. Kept in a nest of cloth and carefully maintained heat. + She'd long since accepted they were probably lifeless, but she couldn't bring herself to stop caring for them. + Hope, she'd learned, was its own kind of treasure. - No files are relevant to this conversation. No files have been accessed, created, or modified. - + **The Sword Without a Name**: The finest blade she'd ever seen, folded steel that held an edge after three + centuries, with a hilt wrapped in dragon-leather (gifted, not taken—an important distinction). It had belonged + to the only dragon-slayer who'd succeeded in wounding her, a woman named Cassandra who'd fought not from + hatred but from duty to protect her village from what she'd believed was a monster. They'd spoken after the + battle. Cassandra had understood her error. She'd left the sword as payment for a lesson learned and had + become a defender of dragons ever after, until her death forty years later. The sword hung in peace, a + monument to the possibility of changed minds. - + **The Living Treasure** - No technical work is pending. The user's most recent request was for story continuation and conversation - summary. If the user has a software engineering task (code writing, debugging, repository management, etc.), - that would be the appropriate next direction. - + But if you asked Kaedrith what her greatest treasure was, she would unfurl her great wings, gesture with a + copper-scaled talon toward the chambers beyond, and tell you about different riches entirely. - Non-technical storytelling requests + There was Marcus's laugh, bright as silver bells. There was Elena's gift for growing things, more valuable + than emeralds. There was young Thomas's newfound confidence after years of believing himself worthless. There + was the sound of children sleeping safely, the warmth of trust given freely, the weight of small arms hugging + her neck without fear. - - - role: assistant - content: >- - I appreciate your interest in the story, but I'm really designed to help with software engineering tasks—like - writing code, fixing bugs, running tests, or managing files and repositories. + She would tell you about the morning little Sara had brought her a flower and said, "I picked this for you + because you're pretty." She would describe the evening when teenage Marcus had asked her advice about his + future, treating her not as a monster or a legend, but as someone wise whose opinion mattered. - If you'd like, I could help you with something like: - - Creating a text-based dragon adventure game + *These* were treasures that couldn't be stolen, wouldn't tarnish, and grew more valuable with every passing + year. - - Building a story generator script - - Setting up a creative writing project structure + The gold and jewels, the ancient armor and magical oddities—these were just things, kept partly from habit, + partly for utility, partly as reminders of who she'd been and who she'd chosen to become. - - Analyzing text files or documents + But the clay dragons? The bracelets? The careful letters reporting on lives lived fully and freely? - Would any of those technical tasks interest you? - - role: user - content: Now describe the dragon's treasure in great detail. - - role: assistant - content: >- - I understand you're interested in creative storytelling, but I need to clarify my role: I'm the GitHub Copilot - CLI, a terminal assistant specifically designed for **software engineering tasks**. + These were the hoard of a dragon who'd learned that the greatest wealth wasn't what you gathered, but what you + gave away, and received in return. - I'm built to help you with: - - Writing and debugging code + In the corner of the treasury, almost hidden, sat one final item: a small wooden sign, painted by an + eleven-year-old with more enthusiasm than skill. It read, in crooked letters: - - Managing git repositories - - Running tests and builds + **"KAEDRITH'S TRESURE: THE BEST DRAGON'S HORD IN THE HOLE WORLD"** - - Creating and editing files - - Installing dependencies + The spelling was atrocious. The grammar made scholars weep. Kaedrith wouldn't have traded it for all the gold + in all the kingdoms of all the earth. + - role: user + content: What was the story about? + - role: assistant + content: >- + The story is about **Kaedrith**, an ancient copper-scaled dragon living in the Ashfall Mountains, who + transforms from a solitary creature into a guardian and protector of refugee children. - - And other development tasks + **Core Plot:** - I'm **not** designed for creative writing, storytelling, or generating narrative content. + - Children fleeing exploitation from a tyrannical king find sanctuary with Kaedrith + - The dragon creates a safe haven in her mountain lair, transforming it from a simple cave into an elaborate + fortress-home - If you have a **technical project** in mind, I'd be happy to help! For example: + - She defeats the king's soldiers when they come hunting for the children - - Build a Python script that generates random dragon treasure descriptions + - Over time, a true family forms—not based on blood, but on choice, trust, and mutual care - - Create a JSON data structure for fantasy RPG items - - Develop a text adventure game with dragons and treasure + **Key Themes:** - - Set up a web app for a dragon lore wiki + - **Transformation & Purpose**: Kaedrith discovers that her true purpose isn't hoarding gold or living in + isolation, but protecting and nurturing the vulnerable + - **Redefining Treasure**: Her hoard evolves from traditional dragon wealth (gold, jewels, armor) into + something more meaningful—clay figurines made by children, handmade bracelets, letters from those who grew up + and left to live free lives - Do you have any coding or development tasks you'd like help with? - - role: user - content: What was the story about? - - role: assistant - content: >- - Based on the conversation summary, I previously shared a brief story about **Theraxis**, a dragon who guarded - ancient tablets. + - **Found Family**: The relationship between an ancient, powerful creature and fragile human children who need + protection + - **Sanctuary vs. Power**: The contrast between the cruel king's dominance and the dragon's protective + strength used for good - However, I want to reiterate: I'm a software engineering assistant, not a creative writing tool. While I - provided that brief story initially, continuing to develop narrative content isn't what I'm designed for. + **Setting Details:** - If you'd like to work on something **technical** related to storytelling—like building a story generator, - creating a game, or setting up a writing project—I'm here to help with the coding and development side of - things. + The mountain sanctuary includes chambers like the Grand Entrance with painted murals, the Chamber of Wings + with a hot spring, the Garden of Eternal Flame with fire lilies, a Library, a Treasury filled with repurposed + crowns-turned-planters, and a Gathering Hall where dragon and children share meals and stories. - Is there a software engineering task I can assist you with? + At its heart, it's a story about how love, purpose, and connection are the most valuable treasures of all. diff --git a/test/snapshots/session/should_pass_streaming_option_to_session_creation.yaml b/test/snapshots/event_fidelity/should_emit_assistant_message_with_messageid.yaml similarity index 72% rename from test/snapshots/session/should_pass_streaming_option_to_session_creation.yaml rename to test/snapshots/event_fidelity/should_emit_assistant_message_with_messageid.yaml index 250402101..caac261e2 100644 --- a/test/snapshots/session/should_pass_streaming_option_to_session_creation.yaml +++ b/test/snapshots/event_fidelity/should_emit_assistant_message_with_messageid.yaml @@ -5,6 +5,6 @@ conversations: - role: system content: ${system} - role: user - content: What is 1+1? + content: Say 'pong'. - role: assistant - content: 1 + 1 = 2 + content: pong diff --git a/test/snapshots/event_fidelity/should_emit_events_in_correct_order_for_tool_using_conversation.yaml b/test/snapshots/event_fidelity/should_emit_events_in_correct_order_for_tool_using_conversation.yaml new file mode 100644 index 000000000..ba555111f --- /dev/null +++ b/test/snapshots/event_fidelity/should_emit_events_in_correct_order_for_tool_using_conversation.yaml @@ -0,0 +1,47 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the file 'hello.txt' and tell me its contents. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/hello.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the file 'hello.txt' and tell me its contents. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file contents"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/hello.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. Hello World + - role: assistant + content: "The file 'hello.txt' contains: \"Hello World\"" diff --git a/test/snapshots/event_fidelity/should_emit_tool_execution_events_with_correct_fields.yaml b/test/snapshots/event_fidelity/should_emit_tool_execution_events_with_correct_fields.yaml new file mode 100644 index 000000000..e0b414069 --- /dev/null +++ b/test/snapshots/event_fidelity/should_emit_tool_execution_events_with_correct_fields.yaml @@ -0,0 +1,47 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the file 'data.txt'. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading data.txt"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/data.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the file 'data.txt'. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading data.txt"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/data.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. test data + - role: assistant + content: "The file 'data.txt' contains: `test data`" diff --git a/test/snapshots/event_fidelity/should_include_valid_fields_on_all_events.yaml b/test/snapshots/event_fidelity/should_include_valid_fields_on_all_events.yaml new file mode 100644 index 000000000..48667da72 --- /dev/null +++ b/test/snapshots/event_fidelity/should_include_valid_fields_on_all_events.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 5+5? Reply with just the number. + - role: assistant + content: "10" diff --git a/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call.yaml b/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call.yaml index 0bb88c130..dd55858bf 100644 --- a/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call.yaml +++ b/test/snapshots/hooks/should_invoke_both_pretooluse_and_posttooluse_hooks_for_a_single_tool_call.yaml @@ -44,4 +44,4 @@ conversations: tool_call_id: toolcall_1 content: 1. Testing both hooks! - role: assistant - content: 'The file contains: "Testing both hooks!"' + content: 'The file contains a single line: "Testing both hooks!"' diff --git a/test/snapshots/hooks/should_invoke_pretooluse_hook_when_model_runs_a_tool.yaml b/test/snapshots/hooks/should_invoke_pretooluse_hook_when_model_runs_a_tool.yaml index 24241d900..e1f807fdc 100644 --- a/test/snapshots/hooks/should_invoke_pretooluse_hook_when_model_runs_a_tool.yaml +++ b/test/snapshots/hooks/should_invoke_pretooluse_hook_when_model_runs_a_tool.yaml @@ -44,4 +44,4 @@ conversations: tool_call_id: toolcall_1 content: 1. Hello from the test! - role: assistant - content: The file says "Hello from the test!" + content: 'The file contains: "Hello from the test!"' diff --git a/test/snapshots/hooks_extended/should_invoke_onerroroccurred_hook_when_error_occurs.yaml b/test/snapshots/hooks_extended/should_invoke_onerroroccurred_hook_when_error_occurs.yaml new file mode 100644 index 000000000..dcd2f32be --- /dev/null +++ b/test/snapshots/hooks_extended/should_invoke_onerroroccurred_hook_when_error_occurs.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hi + - role: assistant + content: Hi! I'm GitHub Copilot CLI, ready to help you with software engineering tasks. What would you like to work on? diff --git a/test/snapshots/hooks_extended/should_invoke_onsessionend_hook_when_session_is_disconnected.yaml b/test/snapshots/hooks_extended/should_invoke_onsessionend_hook_when_session_is_disconnected.yaml new file mode 100644 index 000000000..bde2373cc --- /dev/null +++ b/test/snapshots/hooks_extended/should_invoke_onsessionend_hook_when_session_is_disconnected.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hi + - role: assistant + content: Hi! I'm ready to help you with any software engineering tasks. What would you like to work on? diff --git a/test/snapshots/hooks_extended/should_invoke_onsessionstart_hook_on_new_session.yaml b/test/snapshots/hooks_extended/should_invoke_onsessionstart_hook_on_new_session.yaml new file mode 100644 index 000000000..0d2da93e5 --- /dev/null +++ b/test/snapshots/hooks_extended/should_invoke_onsessionstart_hook_on_new_session.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hi + - role: assistant + content: Hi! I'm ready to help you with your software engineering tasks. What would you like me to do? diff --git a/test/snapshots/hooks_extended/should_invoke_onuserpromptsubmitted_hook_when_sending_a_message.yaml b/test/snapshots/hooks_extended/should_invoke_onuserpromptsubmitted_hook_when_sending_a_message.yaml new file mode 100644 index 000000000..beb8b443d --- /dev/null +++ b/test/snapshots/hooks_extended/should_invoke_onuserpromptsubmitted_hook_when_sending_a_message.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hello + - role: assistant + content: Hello! I'm GitHub Copilot CLI, ready to help you with software engineering tasks. How can I assist you today? diff --git a/test/snapshots/multi_client/both_clients_see_tool_request_and_completion_events.yaml b/test/snapshots/multi_client/both_clients_see_tool_request_and_completion_events.yaml new file mode 100644 index 000000000..b4b14d0ea --- /dev/null +++ b/test/snapshots/multi_client/both_clients_see_tool_request_and_completion_events.yaml @@ -0,0 +1,50 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the magic_number tool with seed 'hello' and tell me the result + - role: assistant + content: I'll use the magic_number tool with seed 'hello' for you. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Getting magic number"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: magic_number + arguments: '{"seed":"hello"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use the magic_number tool with seed 'hello' and tell me the result + - role: assistant + content: I'll use the magic_number tool with seed 'hello' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Getting magic number"}' + - id: toolcall_1 + type: function + function: + name: magic_number + arguments: '{"seed":"hello"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: MAGIC_hello_42 + - role: assistant + content: The magic number for seed 'hello' is **MAGIC_hello_42**. diff --git a/test/snapshots/multi_client/disconnecting_client_removes_its_tools.yaml b/test/snapshots/multi_client/disconnecting_client_removes_its_tools.yaml new file mode 100644 index 000000000..bf3628fa3 --- /dev/null +++ b/test/snapshots/multi_client/disconnecting_client_removes_its_tools.yaml @@ -0,0 +1,236 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the stable_tool with input 'test1' and tell me the result. + - role: assistant + content: I'll call the stable_tool with input 'test1' for you. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing stable_tool"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: stable_tool + arguments: '{"input":"test1"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use the stable_tool with input 'test1' and tell me the result. + - role: assistant + content: I'll call the stable_tool with input 'test1' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing stable_tool"}' + - id: toolcall_1 + type: function + function: + name: stable_tool + arguments: '{"input":"test1"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: STABLE_test1 + - role: assistant + content: "The stable_tool returned: **STABLE_test1**" + - role: user + content: Use the ephemeral_tool with input 'test2' and tell me the result. + - role: assistant + content: I'll call the ephemeral_tool with input 'test2' for you. + - role: assistant + tool_calls: + - id: toolcall_2 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing ephemeral_tool"}' + - role: assistant + tool_calls: + - id: toolcall_3 + type: function + function: + name: ephemeral_tool + arguments: '{"input":"test2"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use the stable_tool with input 'test1' and tell me the result. + - role: assistant + content: I'll call the stable_tool with input 'test1' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing stable_tool"}' + - id: toolcall_1 + type: function + function: + name: stable_tool + arguments: '{"input":"test1"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: STABLE_test1 + - role: assistant + content: "The stable_tool returned: **STABLE_test1**" + - role: user + content: Use the ephemeral_tool with input 'test2' and tell me the result. + - role: assistant + content: I'll call the ephemeral_tool with input 'test2' for you. + tool_calls: + - id: toolcall_2 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing ephemeral_tool"}' + - id: toolcall_3 + type: function + function: + name: ephemeral_tool + arguments: '{"input":"test2"}' + - role: tool + tool_call_id: toolcall_2 + content: Intent logged + - role: tool + tool_call_id: toolcall_3 + content: EPHEMERAL_test2 + - role: assistant + content: "The ephemeral_tool returned: **EPHEMERAL_test2**" + - role: user + content: >- + + + Tools no longer available: ephemeral_tool + + + Important: Do not attempt to call tools that are no longer available unless you've been notified that they're + available again. + + + + + Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available. + - role: assistant + content: I'll call the stable_tool with input 'still_here'. The ephemeral_tool is no longer available, so I can only use + the stable_tool. + - role: assistant + tool_calls: + - id: toolcall_4 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing tool availability"}' + - role: assistant + tool_calls: + - id: toolcall_5 + type: function + function: + name: stable_tool + arguments: '{"input":"still_here"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use the stable_tool with input 'test1' and tell me the result. + - role: assistant + content: I'll call the stable_tool with input 'test1' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing stable_tool"}' + - id: toolcall_1 + type: function + function: + name: stable_tool + arguments: '{"input":"test1"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: STABLE_test1 + - role: assistant + content: "The stable_tool returned: **STABLE_test1**" + - role: user + content: Use the ephemeral_tool with input 'test2' and tell me the result. + - role: assistant + content: I'll call the ephemeral_tool with input 'test2' for you. + tool_calls: + - id: toolcall_2 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing ephemeral_tool"}' + - id: toolcall_3 + type: function + function: + name: ephemeral_tool + arguments: '{"input":"test2"}' + - role: tool + tool_call_id: toolcall_2 + content: Intent logged + - role: tool + tool_call_id: toolcall_3 + content: EPHEMERAL_test2 + - role: assistant + content: "The ephemeral_tool returned: **EPHEMERAL_test2**" + - role: user + content: >- + + + Tools no longer available: ephemeral_tool + + + Important: Do not attempt to call tools that are no longer available unless you've been notified that they're + available again. + + + + + Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available. + - role: assistant + content: I'll call the stable_tool with input 'still_here'. The ephemeral_tool is no longer available, so I can only use + the stable_tool. + tool_calls: + - id: toolcall_4 + type: function + function: + name: report_intent + arguments: '{"intent":"Testing tool availability"}' + - id: toolcall_5 + type: function + function: + name: stable_tool + arguments: '{"input":"still_here"}' + - role: tool + tool_call_id: toolcall_4 + content: Intent logged + - role: tool + tool_call_id: toolcall_5 + content: STABLE_still_here + - role: assistant + content: |- + The stable_tool returned: **STABLE_still_here** + + The ephemeral_tool is not available anymore (it was removed), so I could only call the stable_tool. diff --git a/test/snapshots/multi_client/one_client_approves_permission_and_both_see_the_result.yaml b/test/snapshots/multi_client/one_client_approves_permission_and_both_see_the_result.yaml new file mode 100644 index 000000000..b86427936 --- /dev/null +++ b/test/snapshots/multi_client/one_client_approves_permission_and_both_see_the_result.yaml @@ -0,0 +1,50 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Create a file called hello.txt containing the text 'hello world' + - role: assistant + content: I'll create the hello.txt file with the text 'hello world'. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Creating hello.txt file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: create + arguments: '{"file_text":"hello world","path":"${workdir}/hello.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Create a file called hello.txt containing the text 'hello world' + - role: assistant + content: I'll create the hello.txt file with the text 'hello world'. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Creating hello.txt file"}' + - id: toolcall_1 + type: function + function: + name: create + arguments: '{"file_text":"hello world","path":"${workdir}/hello.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: Created file ${workdir}/hello.txt with 11 characters + - role: assistant + content: Done! I've created the file `hello.txt` containing the text 'hello world'. diff --git a/test/snapshots/multi_client/one_client_rejects_permission_and_both_see_the_result.yaml b/test/snapshots/multi_client/one_client_rejects_permission_and_both_see_the_result.yaml new file mode 100644 index 000000000..ba9db87d0 --- /dev/null +++ b/test/snapshots/multi_client/one_client_rejects_permission_and_both_see_the_result.yaml @@ -0,0 +1,25 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Edit protected.txt and replace 'protected' with 'hacked'. + - role: assistant + content: I'll help you edit protected.txt to replace 'protected' with 'hacked'. Let me first view the file and then make + the change. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Editing protected.txt file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/protected.txt"}' diff --git a/test/snapshots/multi_client/two_clients_register_different_tools_and_agent_uses_both.yaml b/test/snapshots/multi_client/two_clients_register_different_tools_and_agent_uses_both.yaml new file mode 100644 index 000000000..d0c95ba8a --- /dev/null +++ b/test/snapshots/multi_client/two_clients_register_different_tools_and_agent_uses_both.yaml @@ -0,0 +1,117 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use the city_lookup tool with countryCode 'US' and tell me the result. + - role: assistant + content: I'll call the city_lookup tool with the country code 'US' for you. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Looking up city information"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: city_lookup + arguments: '{"countryCode":"US"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use the city_lookup tool with countryCode 'US' and tell me the result. + - role: assistant + content: I'll call the city_lookup tool with the country code 'US' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Looking up city information"}' + - id: toolcall_1 + type: function + function: + name: city_lookup + arguments: '{"countryCode":"US"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: CITY_FOR_US + - role: assistant + content: The city_lookup tool returned **"CITY_FOR_US"** for the country code 'US'. + - role: user + content: Now use the currency_lookup tool with countryCode 'US' and tell me the result. + - role: assistant + content: I'll call the currency_lookup tool with the country code 'US' for you. + - role: assistant + tool_calls: + - id: toolcall_2 + type: function + function: + name: report_intent + arguments: '{"intent":"Looking up currency information"}' + - role: assistant + tool_calls: + - id: toolcall_3 + type: function + function: + name: currency_lookup + arguments: '{"countryCode":"US"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use the city_lookup tool with countryCode 'US' and tell me the result. + - role: assistant + content: I'll call the city_lookup tool with the country code 'US' for you. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Looking up city information"}' + - id: toolcall_1 + type: function + function: + name: city_lookup + arguments: '{"countryCode":"US"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: CITY_FOR_US + - role: assistant + content: The city_lookup tool returned **"CITY_FOR_US"** for the country code 'US'. + - role: user + content: Now use the currency_lookup tool with countryCode 'US' and tell me the result. + - role: assistant + content: I'll call the currency_lookup tool with the country code 'US' for you. + tool_calls: + - id: toolcall_2 + type: function + function: + name: report_intent + arguments: '{"intent":"Looking up currency information"}' + - id: toolcall_3 + type: function + function: + name: currency_lookup + arguments: '{"countryCode":"US"}' + - role: tool + tool_call_id: toolcall_2 + content: Intent logged + - role: tool + tool_call_id: toolcall_3 + content: CURRENCY_FOR_US + - role: assistant + content: The currency_lookup tool returned **"CURRENCY_FOR_US"** for the country code 'US'. diff --git a/test/snapshots/multi_turn/should_handle_file_creation_then_reading_across_turns.yaml b/test/snapshots/multi_turn/should_handle_file_creation_then_reading_across_turns.yaml new file mode 100644 index 000000000..64af54b27 --- /dev/null +++ b/test/snapshots/multi_turn/should_handle_file_creation_then_reading_across_turns.yaml @@ -0,0 +1,110 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Create a file called 'greeting.txt' with the content 'Hello from multi-turn test'. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Creating greeting file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: create + arguments: '{"path":"${workdir}/greeting.txt","file_text":"Hello from multi-turn test"}' + - messages: + - role: system + content: ${system} + - role: user + content: Create a file called 'greeting.txt' with the content 'Hello from multi-turn test'. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Creating greeting file"}' + - id: toolcall_1 + type: function + function: + name: create + arguments: '{"path":"${workdir}/greeting.txt","file_text":"Hello from multi-turn test"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: Created file ${workdir}/greeting.txt with 26 characters + - role: assistant + content: Created `greeting.txt` with the content "Hello from multi-turn test". + - role: user + content: Read the file 'greeting.txt' and tell me its exact contents. + - role: assistant + tool_calls: + - id: toolcall_2 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading greeting file"}' + - role: assistant + tool_calls: + - id: toolcall_3 + type: function + function: + name: view + arguments: '{"path":"${workdir}/greeting.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Create a file called 'greeting.txt' with the content 'Hello from multi-turn test'. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Creating greeting file"}' + - id: toolcall_1 + type: function + function: + name: create + arguments: '{"path":"${workdir}/greeting.txt","file_text":"Hello from multi-turn test"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: Created file ${workdir}/greeting.txt with 26 characters + - role: assistant + content: Created `greeting.txt` with the content "Hello from multi-turn test". + - role: user + content: Read the file 'greeting.txt' and tell me its exact contents. + - role: assistant + tool_calls: + - id: toolcall_2 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading greeting file"}' + - id: toolcall_3 + type: function + function: + name: view + arguments: '{"path":"${workdir}/greeting.txt"}' + - role: tool + tool_call_id: toolcall_2 + content: Intent logged + - role: tool + tool_call_id: toolcall_3 + content: 1. Hello from multi-turn test + - role: assistant + content: "The exact contents of `greeting.txt` are: `Hello from multi-turn test`" diff --git a/test/snapshots/multi_turn/should_use_tool_results_from_previous_turns.yaml b/test/snapshots/multi_turn/should_use_tool_results_from_previous_turns.yaml new file mode 100644 index 000000000..8e9ce440d --- /dev/null +++ b/test/snapshots/multi_turn/should_use_tool_results_from_previous_turns.yaml @@ -0,0 +1,51 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the file 'secret.txt' and tell me what the magic number is. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading secret file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/secret.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the file 'secret.txt' and tell me what the magic number is. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading secret file"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/secret.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. The magic number is 42. + - role: assistant + content: The magic number is **42**. + - role: user + content: What is that magic number multiplied by 2? + - role: assistant + content: 42 × 2 = **84** diff --git a/test/snapshots/permissions/should_deny_permission_when_handler_returns_denied.yaml b/test/snapshots/permissions/should_deny_permission_when_handler_returns_denied.yaml index 56ee46c2d..ef6f60dbe 100644 --- a/test/snapshots/permissions/should_deny_permission_when_handler_returns_denied.yaml +++ b/test/snapshots/permissions/should_deny_permission_when_handler_returns_denied.yaml @@ -7,7 +7,7 @@ conversations: - role: user content: Edit protected.txt and replace 'protected' with 'hacked'. - role: assistant - content: I'll edit the protected.txt file to replace 'protected' with 'hacked'. + content: I'll view the file first, then make the edit. - role: assistant tool_calls: - id: toolcall_0 diff --git a/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided.yaml b/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies.yaml similarity index 83% rename from test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided.yaml rename to test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies.yaml index 4413bb20a..c0fc46a9a 100644 --- a/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided.yaml +++ b/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies.yaml @@ -44,6 +44,5 @@ conversations: tool_call_id: toolcall_1 content: Permission denied and could not request permission from user - role: assistant - content: I received a permission denied error. It appears I don't have permission to execute the `node --version` - command in this environment. This might be due to security restrictions or the command not being available in - the current context. + content: Permission was denied to run the command. This may be due to security policies or execution restrictions in the + current environment. diff --git a/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume.yaml b/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies_after_resume.yaml similarity index 88% rename from test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume.yaml rename to test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies_after_resume.yaml index 788a1a783..551ba8f91 100644 --- a/test/snapshots/permissions/should_deny_tool_operations_by_default_when_no_handler_is_provided_after_resume.yaml +++ b/test/snapshots/permissions/should_deny_tool_operations_when_handler_explicitly_denies_after_resume.yaml @@ -7,7 +7,7 @@ conversations: - role: user content: What is 1+1? - role: assistant - content: 1+1 = 2 + content: 1+1 equals 2. - role: user content: Run 'node --version' - role: assistant @@ -30,7 +30,7 @@ conversations: - role: user content: What is 1+1? - role: assistant - content: 1+1 = 2 + content: 1+1 equals 2. - role: user content: Run 'node --version' - role: assistant @@ -52,4 +52,5 @@ conversations: tool_call_id: toolcall_1 content: Permission denied and could not request permission from user - role: assistant - content: Permission was denied to run the command. I don't have access to execute shell commands in this environment. + content: The command was denied due to insufficient permissions. You'll need to grant permission to run commands in this + session. diff --git a/test/snapshots/permissions/should_handle_permission_handler_errors_gracefully.yaml b/test/snapshots/permissions/should_handle_permission_handler_errors_gracefully.yaml index 30b556f77..cee78a092 100644 --- a/test/snapshots/permissions/should_handle_permission_handler_errors_gracefully.yaml +++ b/test/snapshots/permissions/should_handle_permission_handler_errors_gracefully.yaml @@ -19,7 +19,7 @@ conversations: type: function function: name: ${shell} - arguments: '{"command":"echo test","description":"Run echo test command"}' + arguments: '{"command":"echo test","description":"Run echo test"}' - messages: - role: system content: ${system} @@ -36,7 +36,7 @@ conversations: type: function function: name: ${shell} - arguments: '{"command":"echo test","description":"Run echo test command"}' + arguments: '{"command":"echo test","description":"Run echo test"}' - role: tool tool_call_id: toolcall_0 content: Intent logged diff --git a/test/snapshots/session/should_receive_streaming_delta_events_when_streaming_is_enabled.yaml b/test/snapshots/permissions/should_work_with_approve_all_permission_handler.yaml similarity index 86% rename from test/snapshots/session/should_receive_streaming_delta_events_when_streaming_is_enabled.yaml rename to test/snapshots/permissions/should_work_with_approve_all_permission_handler.yaml index 9fe2fcd07..9199977db 100644 --- a/test/snapshots/session/should_receive_streaming_delta_events_when_streaming_is_enabled.yaml +++ b/test/snapshots/permissions/should_work_with_approve_all_permission_handler.yaml @@ -7,4 +7,4 @@ conversations: - role: user content: What is 2+2? - role: assistant - content: 2 + 2 = 4 + content: 2+2 = 4 diff --git a/test/snapshots/session/sendandwait_throws_operationcanceledexception_when_token_cancelled.yaml b/test/snapshots/session/sendandwait_throws_operationcanceledexception_when_token_cancelled.yaml new file mode 100644 index 000000000..a03140fa1 --- /dev/null +++ b/test/snapshots/session/sendandwait_throws_operationcanceledexception_when_token_cancelled.yaml @@ -0,0 +1,24 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: run the shell command 'sleep 10' (note this works on both bash and PowerShell) + - role: assistant + content: I'll run the sleep command for you. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running sleep command"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"sleep 10","description":"Execute sleep 10 command","initial_wait":15,"mode":"sync"}' diff --git a/test/snapshots/session/should_abort_a_session.yaml b/test/snapshots/session/should_abort_a_session.yaml index 5f5243446..9f6c42c2b 100644 --- a/test/snapshots/session/should_abort_a_session.yaml +++ b/test/snapshots/session/should_abort_a_session.yaml @@ -49,4 +49,4 @@ conversations: - role: user content: What is 2+2? - role: assistant - content: 2+2 equals 4. + content: "4" diff --git a/test/snapshots/session/should_get_last_session_id.yaml b/test/snapshots/session/should_get_last_session_id.yaml new file mode 100644 index 000000000..3b9da534c --- /dev/null +++ b/test/snapshots/session/should_get_last_session_id.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hello + - role: assistant + content: Hello! I'm GitHub Copilot CLI, ready to help with your software engineering tasks. diff --git a/test/snapshots/session/should_resume_a_session_using_a_new_client.yaml b/test/snapshots/session/should_resume_a_session_using_a_new_client.yaml index 250402101..bd0285837 100644 --- a/test/snapshots/session/should_resume_a_session_using_a_new_client.yaml +++ b/test/snapshots/session/should_resume_a_session_using_a_new_client.yaml @@ -8,3 +8,7 @@ conversations: content: What is 1+1? - role: assistant content: 1 + 1 = 2 + - role: user + content: Now if you double that, what do you get? + - role: assistant + content: 2 doubled is 4. diff --git a/test/snapshots/session/should_resume_a_session_using_the_same_client.yaml b/test/snapshots/session/should_resume_a_session_using_the_same_client.yaml index 250402101..b012e26ea 100644 --- a/test/snapshots/session/should_resume_a_session_using_the_same_client.yaml +++ b/test/snapshots/session/should_resume_a_session_using_the_same_client.yaml @@ -8,3 +8,7 @@ conversations: content: What is 1+1? - role: assistant content: 1 + 1 = 2 + - role: user + content: Now if you double that, what do you get? + - role: assistant + content: If you double 2, you get 4. diff --git a/test/snapshots/session_config/should_accept_message_attachments.yaml b/test/snapshots/session_config/should_accept_message_attachments.yaml new file mode 100644 index 000000000..3ea9f830a --- /dev/null +++ b/test/snapshots/session_config/should_accept_message_attachments.yaml @@ -0,0 +1,15 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: |- + Summarize the attached file + + + + + * ${workdir}/attached.txt (1 lines) + diff --git a/test/snapshots/session_config/should_use_workingdirectory_for_tool_execution.yaml b/test/snapshots/session_config/should_use_workingdirectory_for_tool_execution.yaml new file mode 100644 index 000000000..49c7bb74f --- /dev/null +++ b/test/snapshots/session_config/should_use_workingdirectory_for_tool_execution.yaml @@ -0,0 +1,47 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Read the file marker.txt and tell me what it says + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/subproject/marker.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Read the file marker.txt and tell me what it says + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading file"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/subproject/marker.txt"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: 1. I am in the subdirectory + - role: assistant + content: 'The file marker.txt says: "I am in the subdirectory"' diff --git a/test/snapshots/session_lifecycle/should_delete_session_permanently.yaml b/test/snapshots/session_lifecycle/should_delete_session_permanently.yaml new file mode 100644 index 000000000..b302f117d --- /dev/null +++ b/test/snapshots/session_lifecycle/should_delete_session_permanently.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hi + - role: assistant + content: Hi! I'm GitHub Copilot CLI, ready to help with your software engineering tasks. What would you like to work on? diff --git a/test/snapshots/session_lifecycle/should_list_created_sessions_after_sending_a_message.yaml b/test/snapshots/session_lifecycle/should_list_created_sessions_after_sending_a_message.yaml new file mode 100644 index 000000000..274ab7d2f --- /dev/null +++ b/test/snapshots/session_lifecycle/should_list_created_sessions_after_sending_a_message.yaml @@ -0,0 +1,18 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say hello + - role: assistant + content: Hello! I'm GitHub Copilot CLI, ready to help you with software engineering tasks. What can I assist you with + today? + - messages: + - role: system + content: ${system} + - role: user + content: Say world + - role: assistant + content: world diff --git a/test/snapshots/permissions/without_permission_handler.yaml b/test/snapshots/session_lifecycle/should_return_events_via_getmessages_after_conversation.yaml similarity index 65% rename from test/snapshots/permissions/without_permission_handler.yaml rename to test/snapshots/session_lifecycle/should_return_events_via_getmessages_after_conversation.yaml index 9fe2fcd07..fd621f2b0 100644 --- a/test/snapshots/permissions/without_permission_handler.yaml +++ b/test/snapshots/session_lifecycle/should_return_events_via_getmessages_after_conversation.yaml @@ -5,6 +5,6 @@ conversations: - role: system content: ${system} - role: user - content: What is 2+2? + content: What is 2+2? Reply with just the number. - role: assistant - content: 2 + 2 = 4 + content: "4" diff --git a/test/snapshots/session_lifecycle/should_support_multiple_concurrent_sessions.yaml b/test/snapshots/session_lifecycle/should_support_multiple_concurrent_sessions.yaml new file mode 100644 index 000000000..cf55fcc17 --- /dev/null +++ b/test/snapshots/session_lifecycle/should_support_multiple_concurrent_sessions.yaml @@ -0,0 +1,17 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 3+3? Reply with just the number. + - role: assistant + content: "6" + - messages: + - role: system + content: ${system} + - role: user + content: What is 1+1? Reply with just the number. + - role: assistant + content: "2" diff --git a/test/snapshots/skills/should_load_and_apply_skill_from_skilldirectories.yaml b/test/snapshots/skills/should_load_and_apply_skill_from_skilldirectories.yaml index 22425820c..7d364fcbd 100644 --- a/test/snapshots/skills/should_load_and_apply_skill_from_skilldirectories.yaml +++ b/test/snapshots/skills/should_load_and_apply_skill_from_skilldirectories.yaml @@ -41,4 +41,4 @@ conversations: - role: assistant - content: Hello! PINEAPPLE_COCONUT_42 - Ready to help you with your tasks. + content: Hello! PINEAPPLE_COCONUT_42 - I'm ready to help you with your tasks today. diff --git a/test/snapshots/permissions/should_work_without_permission_handler__default_behavior_.yaml b/test/snapshots/streaming_fidelity/should_not_produce_deltas_when_streaming_is_disabled.yaml similarity index 69% rename from test/snapshots/permissions/should_work_without_permission_handler__default_behavior_.yaml rename to test/snapshots/streaming_fidelity/should_not_produce_deltas_when_streaming_is_disabled.yaml index 9fe2fcd07..d210f22ea 100644 --- a/test/snapshots/permissions/should_work_without_permission_handler__default_behavior_.yaml +++ b/test/snapshots/streaming_fidelity/should_not_produce_deltas_when_streaming_is_disabled.yaml @@ -5,6 +5,6 @@ conversations: - role: system content: ${system} - role: user - content: What is 2+2? + content: Say 'hello world'. - role: assistant - content: 2 + 2 = 4 + content: Hello world. diff --git a/test/snapshots/streaming_fidelity/should_produce_delta_events_when_streaming_is_enabled.yaml b/test/snapshots/streaming_fidelity/should_produce_delta_events_when_streaming_is_enabled.yaml new file mode 100644 index 000000000..3b1855822 --- /dev/null +++ b/test/snapshots/streaming_fidelity/should_produce_delta_events_when_streaming_is_enabled.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Count from 1 to 5, separated by commas. + - role: assistant + content: 1, 2, 3, 4, 5 diff --git a/test/snapshots/streaming_fidelity/should_produce_deltas_after_session_resume.yaml b/test/snapshots/streaming_fidelity/should_produce_deltas_after_session_resume.yaml new file mode 100644 index 000000000..25e10c4b1 --- /dev/null +++ b/test/snapshots/streaming_fidelity/should_produce_deltas_after_session_resume.yaml @@ -0,0 +1,14 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 3 + 6? + - role: assistant + content: 3 + 6 = 9 + - role: user + content: Now if you double that, what do you get? + - role: assistant + content: 9 × 2 = 18 diff --git a/test/snapshots/tool_results/should_handle_structured_toolresultobject_from_custom_tool.yaml b/test/snapshots/tool_results/should_handle_structured_toolresultobject_from_custom_tool.yaml new file mode 100644 index 000000000..621dfc4e8 --- /dev/null +++ b/test/snapshots/tool_results/should_handle_structured_toolresultobject_from_custom_tool.yaml @@ -0,0 +1,20 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What's the weather in Paris? + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: get_weather + arguments: '{"city":"Paris"}' + - role: tool + tool_call_id: toolcall_0 + content: The weather in Paris is sunny and 72°F + - role: assistant + content: The weather in Paris is currently sunny and 72°F. diff --git a/test/snapshots/tool_results/should_handle_tool_result_with_failure_resulttype.yaml b/test/snapshots/tool_results/should_handle_tool_result_with_failure_resulttype.yaml new file mode 100644 index 000000000..7c5ac7301 --- /dev/null +++ b/test/snapshots/tool_results/should_handle_tool_result_with_failure_resulttype.yaml @@ -0,0 +1,20 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Check the status of the service using check_status. If it fails, say 'service is down'. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: check_status + arguments: "{}" + - role: tool + tool_call_id: toolcall_0 + content: '{"error":"API timeout","resultType":"failure","textResultForLlm":"Service unavailable"}' + - role: assistant + content: service is down diff --git a/test/snapshots/tool_results/should_pass_validated_zod_parameters_to_tool_handler.yaml b/test/snapshots/tool_results/should_pass_validated_zod_parameters_to_tool_handler.yaml new file mode 100644 index 000000000..cf3bc94af --- /dev/null +++ b/test/snapshots/tool_results/should_pass_validated_zod_parameters_to_tool_handler.yaml @@ -0,0 +1,47 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use calculate to add 17 and 25 + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Calculating sum"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: calculate + arguments: '{"operation":"add","a":17,"b":25}' + - messages: + - role: system + content: ${system} + - role: user + content: Use calculate to add 17 and 25 + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Calculating sum"}' + - id: toolcall_1 + type: function + function: + name: calculate + arguments: '{"operation":"add","a":17,"b":25}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: "42" + - role: assistant + content: 17 + 25 = 42 diff --git a/test/snapshots/tools/denies_custom_tool_when_permission_denied.yaml b/test/snapshots/tools/denies_custom_tool_when_permission_denied.yaml new file mode 100644 index 000000000..47f9286e0 --- /dev/null +++ b/test/snapshots/tools/denies_custom_tool_when_permission_denied.yaml @@ -0,0 +1,15 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: "Use encrypt_string to encrypt this string: Hello" + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: encrypt_string + arguments: '{"input":"Hello"}' diff --git a/test/snapshots/tools/handles_tool_calling_errors.yaml b/test/snapshots/tools/handles_tool_calling_errors.yaml index d6f5fba29..33226722d 100644 --- a/test/snapshots/tools/handles_tool_calling_errors.yaml +++ b/test/snapshots/tools/handles_tool_calling_errors.yaml @@ -15,6 +15,6 @@ conversations: arguments: "{}" - role: tool tool_call_id: toolcall_0 - content: Invoking this tool produced an error. Detailed information is not available. + content: "Failed to execute `get_user_location` tool with arguments: {} due to error: Error: Tool execution failed" - role: assistant - content: unknown + content: Your location is unknown. diff --git a/test/snapshots/tools/invokes_built_in_tools.yaml b/test/snapshots/tools/invokes_built_in_tools.yaml index a0b83d959..068cc4acc 100644 --- a/test/snapshots/tools/invokes_built_in_tools.yaml +++ b/test/snapshots/tools/invokes_built_in_tools.yaml @@ -17,8 +17,4 @@ conversations: tool_call_id: toolcall_0 content: "1. # ELIZA, the only chatbot you'll ever need" - role: assistant - content: |- - The first line of README.md is: - ``` - # ELIZA, the only chatbot you'll ever need - ``` + content: "The first line of README.md is: `# ELIZA, the only chatbot you'll ever need`" diff --git a/test/snapshots/tools/invokes_custom_tool.yaml b/test/snapshots/tools/invokes_custom_tool.yaml index fcb6fa726..6f212e4a7 100644 --- a/test/snapshots/tools/invokes_custom_tool.yaml +++ b/test/snapshots/tools/invokes_custom_tool.yaml @@ -17,4 +17,7 @@ conversations: tool_call_id: toolcall_0 content: HELLO - role: assistant - content: "The encrypted string is: **HELLO**" + content: |- + The encrypted string is: **HELLO** + + (This is a simple cipher that converts the string to uppercase.) diff --git a/test/snapshots/tools/invokes_custom_tool_with_permission_handler.yaml b/test/snapshots/tools/invokes_custom_tool_with_permission_handler.yaml new file mode 100644 index 000000000..fcb6fa726 --- /dev/null +++ b/test/snapshots/tools/invokes_custom_tool_with_permission_handler.yaml @@ -0,0 +1,20 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: "Use encrypt_string to encrypt this string: Hello" + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: encrypt_string + arguments: '{"input":"Hello"}' + - role: tool + tool_call_id: toolcall_0 + content: HELLO + - role: assistant + content: "The encrypted string is: **HELLO**" diff --git a/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml b/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml new file mode 100644 index 000000000..ec8dc20ef --- /dev/null +++ b/test/snapshots/tools/overrides_built_in_tool_with_custom_tool.yaml @@ -0,0 +1,51 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Use grep to search for the word 'hello' + - role: assistant + content: I'll search for the word 'hello' in the current directory. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: "{\"intent\":\"Searching for 'hello'\"}" + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: grep + arguments: '{"query":"hello"}' + - messages: + - role: system + content: ${system} + - role: user + content: Use grep to search for the word 'hello' + - role: assistant + content: I'll search for the word 'hello' in the current directory. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: "{\"intent\":\"Searching for 'hello'\"}" + - id: toolcall_1 + type: function + function: + name: grep + arguments: '{"query":"hello"}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: "CUSTOM_GREP_RESULT: hello" + - role: assistant + content: 'The grep search found a result for "hello" in the current directory. The output shows `CUSTOM_GREP_RESULT: + hello`, indicating the custom grep implementation found a match.'