From 1f89a18d7ac39ba3b0490b8442326c4946f101f9 Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Wed, 20 May 2026 14:43:00 +0200 Subject: [PATCH 1/2] Upstream sync: post-v1.0.0-beta.4 round 2 (#1299, #1315) + schema 1.0.49 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the SessionFs SQLite support (upstream PR #1299) and bumps the pinned schema version from 1.0.49-1 to 1.0.49. ### SessionFs SQLite (upstream PR #1299) - New optional `:sqlite {:query :exists}` sub-provider for the provider-style `createSessionFsHandler` factory. Low-level handlers expose flat `:sqlite-query` / `:sqlite-exists` keys; the adapter bridges between them. - Clients advertise support via `:capabilities {:sqlite true}` under `:session-fs`; the value is forwarded on `sessionFs.setProvider` and validated at session creation (throws when capabilities.sqlite is declared but the provider lacks :sqlite). - `queryType` is coerced from the wire string to an idiomatic keyword (`#{:exec :query :run}`) before reaching the handler. - SQL bind-parameter map keys (e.g. `$userId`) bypass kebab-case conversion via a new escape hatch in `protocol/normalize-incoming`. - Result row column-name keys (e.g. `:user_id`, `:created_at`) round-trip verbatim on the outgoing wire path via a new `preserve-outgoing-opaque-fields` escape hatch in `protocol/`, matching upstream Node.js semantics where provider rows are forwarded untouched. (review feedback) - SQLite handler exceptions propagate as JSON-RPC errors (not wrapped as SessionFsError result maps), matching upstream Node behavior. ### Schema 1.0.49 - `.copilot-schema-version` advanced from `1.0.49-1` to `1.0.49` - Additive only: new named enum types, format annotations, and the new sessionFs.sqliteQuery / sessionFs.sqliteExists RPC methods - Regenerated event_specs.clj reflects the new enums - (upstream PRs #1305, #1307, #1327, #1333) ### Other - Updated `examples/permission_bash.clj` from deprecated `:approved` to current `:approve-once` (carried from upstream PR #1315) - Added integration tests covering the SQLite adapter, RPC dispatch, capability forwarding, validation throw, opaque param keys, and the new snake_case column-name round-trip regression test ### Validation - bb test: 269 tests / 1275 assertions pass - bb validate-docs: 0 warnings - ./run-all-examples.sh: all examples pass - E2E: 2 pre-existing failures (test-e2e-connection ping timestamp, test-e2e-blob-attachment timeout) due to local CLI 1.0.51-2 drift from the pinned 1.0.49 schema; unrelated to these changes ### Multi-model code review Parallel reviews via Claude Opus 4.7 and GPT-5.5 both flagged the same HIGH severity bug: outgoing SQL row column names were being mangled by recursive kebab→camelCase conversion (`util/clj->wire`). Fixed by adding `preserve-outgoing-opaque-fields` in protocol.clj, mirroring the existing incoming escape hatch for bind params. Regression test added. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .copilot-schema-version | 2 +- CHANGELOG.md | 28 ++ doc/reference/API.md | 41 ++ examples/permission_bash.clj | 2 +- schemas/README.md | 2 +- schemas/api.schema.json | 460 +++++++++++++----- schemas/session-events.schema.json | 174 ++++--- src/github/copilot_sdk/client.clj | 22 +- .../copilot_sdk/generated/event_specs.clj | 12 +- src/github/copilot_sdk/protocol.clj | 32 +- src/github/copilot_sdk/session.clj | 230 +++++---- src/github/copilot_sdk/specs.clj | 46 +- test/github/copilot_sdk/integration_test.clj | 238 +++++++++ test/github/copilot_sdk/mock_server.clj | 1 + 14 files changed, 991 insertions(+), 299 deletions(-) diff --git a/.copilot-schema-version b/.copilot-schema-version index a700f32..feca5b2 100644 --- a/.copilot-schema-version +++ b/.copilot-schema-version @@ -1 +1 @@ -1.0.49-1 +1.0.49 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dc0ff7..b6ad854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,34 @@ All notable changes to this project will be documented in this file. This change ## [Unreleased] +### Added (post-v1.0.0-beta.4 sync, round 2) +- **SessionFs SQLite support** — `sessionFs.sqliteQuery` and `sessionFs.sqliteExists` + RPCs are now dispatched to a user-supplied provider. The provider-style handler + accepts an optional nested `:sqlite {:query (fn [query-type sql params]) :exists (fn [])}` + map, alongside the existing filesystem keys. The low-level handler shape uses + flat `:sqlite-query` / `:sqlite-exists` keys (the adapter translates between + them). Clients advertise support via `:capabilities {:sqlite true}` under + `:session-fs`; the value is forwarded on `sessionFs.setProvider` and validated + at session creation (declaring `capabilities.sqlite` without providing a + `:sqlite` handler now throws). `query-type` is automatically coerced from the + wire string to a keyword (`#{:exec :query :run}`). SQL bind-parameter keys + (e.g. `$userId`) are preserved verbatim through wire normalization, and + result row column-name keys (e.g. `:user_id`, `:created_at`) round-trip + verbatim on the outgoing wire path — they are no longer mangled by + recursive kebab→camelCase conversion. SQLite errors propagate as JSON-RPC + errors (not wrapped as SessionFsError). (upstream PR #1299) +- **Schema bump** — `.copilot-schema-version` advanced from `1.0.49-1` to `1.0.49`. + Additive changes only: new named enum types (`AutoModeSwitchResponse`, + `ExitPlanModeAction`, `McpServerSource`, `McpServerStatus`, `SessionMode`, + `SkillSource`, renamed `PermissionRequestMemoryAction/Direction`), + `format: "duration"`/`"uri"` annotations, `"max"` value in reasoning-effort + description, plus the new `sessionFs.sqliteQuery` / `sessionFs.sqliteExists` + RPC methods. (upstream PRs #1305, #1307, #1327, #1333) + +### Fixed (post-v1.0.0-beta.4 sync, round 2) +- **`examples/permission_bash.clj`** — Updated permission decision kind from the + deprecated `:approved` to the current `:approve-once`. (carried from upstream PR #1315) + ### Added (post-v1.0.0-beta.4 sync) - **`:session-id` on hook input maps** — `:on-hook-invoke` handlers now receive a `:session-id` key on the input map. When the upstream wire payload includes diff --git a/doc/reference/API.md b/doc/reference/API.md index 2fda24c..157896d 100644 --- a/doc/reference/API.md +++ b/doc/reference/API.md @@ -2179,9 +2179,50 @@ The low-level handler map requires all 10 operations: | `:readdir-with-types` | `{:session-id :path}` | `{:entries [...]}` | | `:rm` | `{:session-id :path :recursive :force}` | nil | | `:rename` | `{:session-id :src :dest}` | nil | +| `:sqlite-query` | `{:session-id :query-type :query :params}` | `{:rows [...] :columns [...] :rows-affected n}` (optional) | +| `:sqlite-exists` | `{:session-id}` | `{:exists true/false}` (optional) | Handler functions may return values directly or via core.async channels. +#### SQLite support (optional) + +To handle `sessionFs.sqliteQuery` and `sessionFs.sqliteExists` (upstream PR #1299), +add a nested `:sqlite` map to the provider and advertise the capability on the +client config: + +```clojure +(def client + (copilot/client {:session-fs {:initial-cwd "/home/user/project" + :session-state-path "/sessions" + :conventions "posix" + :capabilities {:sqlite true}}})) + +(def session + (copilot/create-session client + {:on-permission-request copilot/approve-all + :create-session-fs-handler + (fn [_session] + {;; ... all 10 fs operations above ... + :sqlite {:query (fn [query-type sql params] + ;; query-type is one of :exec, :query, :run + ;; params is the raw bind-parameter map (keys preserved verbatim, e.g. :$userId) + {:rows [{:n 1}] :columns ["n"] :rows-affected 0}) + :exists (fn [] true)}})})) +``` + +Notes: + +- `:capabilities {:sqlite true}` is required when sqlite is advertised; declaring + it without supplying `:sqlite` in the provider throws at session creation. +- SQL bind-parameter map keys (e.g. `$userId`) bypass kebab-case conversion and + arrive at the handler verbatim. +- Result row column-name keys (e.g. `:user_id`, `:created_at`) round-trip + verbatim on the outgoing wire path — they are not converted to camelCase, + matching upstream Node.js semantics where provider rows are forwarded + untouched. +- SQLite handler exceptions propagate as JSON-RPC errors (not wrapped as + `SessionFsError`). + ### Session Hooks Lifecycle hooks allow custom logic at various points during the session: diff --git a/examples/permission_bash.clj b/examples/permission_bash.clj index 26ef465..3a938ed 100644 --- a/examples/permission_bash.clj +++ b/examples/permission_bash.clj @@ -27,7 +27,7 @@ :on-permission-request (fn [request _ctx] (pprint/pprint request) (if (contains? allowed-commands (:full-command-text request)) - {:kind :approved} + {:kind :approve-once} {:kind :denied-by-rules :rules [{:kind "shell" :argument (:full-command-text request)}]}))}] diff --git a/schemas/README.md b/schemas/README.md index 49b68f1..d546d29 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -4,4 +4,4 @@ These files are fetched verbatim from the `@github/copilot` npm package at the v **Do not edit by hand.** To update, run `bb schemas:fetch` after bumping `.copilot-schema-version`. -Currently pinned version: `1.0.49-1` +Currently pinned version: `1.0.49` diff --git a/schemas/api.schema.json b/schemas/api.schema.json index 7e23234..d5cd13e 100644 --- a/schemas/api.schema.json +++ b/schemas/api.schema.json @@ -350,7 +350,7 @@ }, "result": { "$ref": "#/definitions/SessionMode", - "description": "The agent mode. Valid values: \"interactive\", \"plan\", \"autopilot\"." + "description": "The session mode the agent is operating in" } }, "set": { @@ -365,7 +365,7 @@ }, "mode": { "$ref": "#/definitions/SessionMode", - "description": "The agent mode. Valid values: \"interactive\", \"plan\", \"autopilot\"." + "description": "The session mode the agent is operating in" } }, "required": [ @@ -2312,6 +2312,72 @@ ], "description": "Describes a filesystem error." } + }, + "sqliteQuery": { + "rpcMethod": "sessionFs.sqliteQuery", + "description": "Executes a SQLite query against the per-session database.", + "params": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Target session identifier" + }, + "query": { + "type": "string", + "description": "SQL query to execute" + }, + "queryType": { + "$ref": "#/definitions/SessionFsSqliteQueryType", + "description": "How to execute the query: 'exec' for DDL/multi-statement (no results), 'query' for SELECT (returns rows), 'run' for INSERT/UPDATE/DELETE (returns rowsAffected)" + }, + "params": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "null" + ] + }, + "description": "Optional named bind parameters" + } + }, + "required": [ + "sessionId", + "query", + "queryType" + ], + "additionalProperties": false, + "description": "SQL query, query type, and optional bind parameters for executing a SQLite query against the per-session database.", + "title": "SessionFsSqliteQueryRequest" + }, + "result": { + "$ref": "#/definitions/SessionFsSqliteQueryResult", + "description": "Query results including rows, columns, and rows affected, or a filesystem error if execution failed." + } + }, + "sqliteExists": { + "rpcMethod": "sessionFs.sqliteExists", + "description": "Checks whether the per-session SQLite database already exists, without creating it.", + "params": { + "type": "object", + "description": "Identifies the target session.", + "properties": { + "sessionId": { + "type": "string", + "description": "Target session identifier" + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/SessionFsSqliteExistsResult", + "description": "Indicates whether the per-session SQLite database already exists." + } } } }, @@ -2362,7 +2428,7 @@ }, "entitlementRequests": { "type": "integer", - "description": "Number of requests included in the entitlement" + "description": "Number of requests included in the entitlement, or -1 for unlimited entitlements" }, "usedRequests": { "type": "integer", @@ -2388,6 +2454,7 @@ }, "resetDate": { "type": "string", + "format": "date-time", "description": "Date when the quota resets (ISO 8601 string)" } }, @@ -2678,10 +2745,12 @@ }, "startTime": { "type": "string", + "format": "date-time", "description": "Session start time as an ISO 8601 string." }, "modifiedTime": { "type": "string", + "format": "date-time", "description": "Last session update time as an ISO 8601 string." }, "repository": { @@ -2702,6 +2771,7 @@ }, "staleAt": { "type": "string", + "format": "date-time", "description": "Remote session staleness deadline as an ISO 8601 string." }, "state": { @@ -2809,6 +2879,16 @@ "title": "ConnectResult", "visibility": "internal" }, + "ContentFilterMode": { + "type": "string", + "enum": [ + "none", + "markdown", + "hidden_characters" + ], + "description": "Controls how MCP tool result content is filtered: none leaves content unchanged, markdown sanitizes HTML while preserving Markdown-friendly output, and hidden_characters removes characters that can hide directives.", + "title": "ContentFilterMode" + }, "CurrentModel": { "type": "object", "properties": { @@ -2832,11 +2912,11 @@ }, "type": { "$ref": "#/definitions/DiscoveredMcpServerType", - "description": "Server transport type: stdio, http, sse, or memory (local configs are normalized to stdio)" + "description": "Server transport type: stdio, http, sse, or memory" }, "source": { - "$ref": "#/definitions/DiscoveredMcpServerSource", - "description": "Configuration source" + "$ref": "#/definitions/McpServerSource", + "description": "Configuration source: user, workspace, plugin, or builtin" }, "enabled": { "type": "boolean", @@ -2852,17 +2932,6 @@ "title": "DiscoveredMcpServer", "description": "Schema for the `DiscoveredMcpServer` type." }, - "DiscoveredMcpServerSource": { - "type": "string", - "enum": [ - "user", - "workspace", - "plugin", - "builtin" - ], - "description": "Configuration source", - "title": "DiscoveredMcpServerSource" - }, "DiscoveredMcpServerType": { "type": "string", "enum": [ @@ -2871,7 +2940,7 @@ "sse", "memory" ], - "description": "Server transport type: stdio, http, sse, or memory (local configs are normalized to stdio)", + "description": "Server transport type: stdio, http, sse, or memory", "title": "DiscoveredMcpServerType" }, "EmbeddedBlobResourceContents": { @@ -3063,6 +3132,14 @@ "additionalProperties": {}, "description": "Optional tool-specific telemetry" }, + "binaryResultsForLlm": { + "type": "array", + "items": { + "$ref": "#/definitions/ExternalToolTextResultForLlmBinaryResultsForLlm", + "description": "Binary result returned by a tool for the model" + }, + "description": "Base64-encoded binary results returned to the model" + }, "contents": { "type": "array", "items": { @@ -3079,6 +3156,45 @@ "description": "Expanded external tool result payload", "title": "ExternalToolTextResultForLlm" }, + "ExternalToolTextResultForLlmBinaryResultsForLlm": { + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/ExternalToolTextResultForLlmBinaryResultsForLlmType", + "description": "Binary result type discriminator. Use \"image\" for images and \"resource\" for other binary data." + }, + "data": { + "type": "string", + "description": "Base64-encoded binary data", + "contentEncoding": "base64" + }, + "mimeType": { + "type": "string", + "description": "MIME type of the binary data" + }, + "description": { + "type": "string", + "description": "Human-readable description of the binary data" + } + }, + "required": [ + "type", + "data", + "mimeType" + ], + "additionalProperties": false, + "description": "Binary result returned by a tool for the model", + "title": "ExternalToolTextResultForLlmBinaryResultsForLlm" + }, + "ExternalToolTextResultForLlmBinaryResultsForLlmType": { + "type": "string", + "enum": [ + "image", + "resource" + ], + "description": "Binary result type discriminator. Use \"image\" for images and \"resource\" for other binary data.", + "title": "ExternalToolTextResultForLlmBinaryResultsForLlmType" + }, "ExternalToolTextResultForLlmContent": { "anyOf": [ { @@ -3340,36 +3456,18 @@ { "type": "object", "additionalProperties": { - "$ref": "#/definitions/FilterMappingValue" + "$ref": "#/definitions/ContentFilterMode", + "description": "Controls how MCP tool result content is filtered: none leaves content unchanged, markdown sanitizes HTML while preserving Markdown-friendly output, and hidden_characters removes characters that can hide directives." } }, { - "$ref": "#/definitions/FilterMappingString" + "$ref": "#/definitions/ContentFilterMode", + "description": "Controls how MCP tool result content is filtered: none leaves content unchanged, markdown sanitizes HTML while preserving Markdown-friendly output, and hidden_characters removes characters that can hide directives." } ], "description": "Content filtering mode to apply to all tools, or a map of tool name to content filtering mode.", "title": "FilterMapping" }, - "FilterMappingString": { - "type": "string", - "enum": [ - "none", - "markdown", - "hidden_characters" - ], - "title": "FilterMappingString", - "description": "Allowed values for the `FilterMappingString` enumeration." - }, - "FilterMappingValue": { - "type": "string", - "enum": [ - "none", - "markdown", - "hidden_characters" - ], - "title": "FilterMappingValue", - "description": "Allowed values for the `FilterMappingValue` enumeration." - }, "FleetStartRequest": { "type": "object", "properties": { @@ -3684,7 +3782,7 @@ }, "config": { "$ref": "#/definitions/McpServerConfig", - "description": "MCP server configuration (local/stdio or remote/http)" + "description": "MCP server configuration (stdio process or remote HTTP/SSE)" } }, "required": [ @@ -3744,7 +3842,7 @@ "type": "object", "additionalProperties": { "$ref": "#/definitions/McpServerConfig", - "description": "MCP server configuration (local/stdio or remote/http)" + "description": "MCP server configuration (stdio process or remote HTTP/SSE)" }, "propertyNames": { "minLength": 1, @@ -3788,7 +3886,7 @@ }, "config": { "$ref": "#/definitions/McpServerConfig", - "description": "MCP server configuration (local/stdio or remote/http)" + "description": "MCP server configuration (stdio process or remote HTTP/SSE)" } }, "required": [ @@ -3897,6 +3995,7 @@ "properties": { "authorizationUrl": { "type": "string", + "format": "uri", "description": "URL the caller should open in a browser to complete OAuth. Omitted when cached tokens were still valid and no browser interaction was needed — the server is already reconnected in that case. When present, the runtime starts the callback listener before returning and continues the flow in the background; completion is signaled via session.mcp_server_status_changed." } }, @@ -3937,15 +4036,15 @@ "McpServerConfig": { "anyOf": [ { - "$ref": "#/definitions/McpServerConfigLocal", - "description": "Local MCP server configuration launched as a child process." + "$ref": "#/definitions/McpServerConfigStdio", + "description": "Stdio MCP server configuration launched as a child process." }, { "$ref": "#/definitions/McpServerConfigHttp", "description": "Remote MCP server configuration accessed over HTTP or SSE." } ], - "description": "MCP server configuration (local/stdio or remote/http)", + "description": "MCP server configuration (stdio process or remote HTTP/SSE)", "title": "McpServerConfig" }, "McpServerConfigHttp": { @@ -4000,6 +4099,10 @@ "oauthGrantType": { "$ref": "#/definitions/McpServerConfigHttpOauthGrantType", "description": "OAuth grant type to use when authenticating to the remote MCP server." + }, + "auth": { + "$ref": "#/definitions/McpServerConfigHttpAuth", + "description": "Additional authentication configuration for this server." } }, "required": [ @@ -4009,6 +4112,20 @@ "description": "Remote MCP server configuration accessed over HTTP or SSE.", "title": "McpServerConfigHttp" }, + "McpServerConfigHttpAuth": { + "type": "object", + "properties": { + "redirectPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "Fixed port for the OAuth redirect callback server." + } + }, + "additionalProperties": false, + "description": "Additional authentication configuration for this server.", + "title": "McpServerConfigHttpAuth" + }, "McpServerConfigHttpOauthGrantType": { "type": "string", "enum": [ @@ -4028,7 +4145,7 @@ "description": "Remote transport type. Defaults to \"http\" when omitted.", "title": "McpServerConfigHttpType" }, - "McpServerConfigLocal": { + "McpServerConfigStdio": { "type": "object", "properties": { "tools": { @@ -4038,10 +4155,6 @@ }, "description": "Tools to include. Defaults to all tools if not specified." }, - "type": { - "$ref": "#/definitions/McpServerConfigLocalType", - "description": "Local transport type. Defaults to \"local\"." - }, "isDefaultServer": { "type": "boolean", "description": "Whether this server is a built-in fallback used when the user has not configured their own server." @@ -4059,43 +4172,34 @@ }, "command": { "type": "string", - "description": "Executable command used to start the local MCP server process." + "description": "Executable command used to start the Stdio MCP server process." }, "args": { "type": "array", "items": { "type": "string" }, - "description": "Command-line arguments passed to the local MCP server process." + "default": [], + "description": "Command-line arguments passed to the Stdio MCP server process." }, "cwd": { "type": "string", - "description": "Working directory for the local MCP server process." + "description": "Working directory for the Stdio MCP server process." }, "env": { "type": "object", "additionalProperties": { "type": "string" }, - "description": "Environment variables to pass to the local MCP server process." + "description": "Environment variables to pass to the Stdio MCP server process." } }, "required": [ - "command", - "args" + "command" ], "additionalProperties": false, - "description": "Local MCP server configuration launched as a child process.", - "title": "McpServerConfigLocal" - }, - "McpServerConfigLocalType": { - "type": "string", - "enum": [ - "local", - "stdio" - ], - "description": "Local transport type. Defaults to \"local\".", - "title": "McpServerConfigLocalType" + "description": "Stdio MCP server configuration launched as a child process.", + "title": "McpServerConfigStdio" }, "McpServerList": { "type": "object", @@ -4447,7 +4551,7 @@ "type": "object", "properties": { "state": { - "type": "string", + "$ref": "#/definitions/ModelPolicyState", "description": "Current policy state for this model" }, "terms": { @@ -4462,6 +4566,16 @@ "description": "Policy state (if applicable)", "title": "ModelPolicy" }, + "ModelPolicyState": { + "type": "string", + "enum": [ + "enabled", + "disabled", + "unconfigured" + ], + "description": "Current policy state for this model", + "title": "ModelPolicyState" + }, "ModelsListRequest": { "anyOf": [ { @@ -4525,7 +4639,7 @@ "properties": { "mode": { "$ref": "#/definitions/SessionMode", - "description": "The agent mode. Valid values: \"interactive\", \"plan\", \"autopilot\"." + "description": "The session mode the agent is operating in" } }, "required": [ @@ -5443,6 +5557,7 @@ "properties": { "url": { "type": "string", + "format": "uri", "description": "GitHub frontend URL for this session" }, "remoteSteerable": { @@ -5499,7 +5614,7 @@ "description": "Description of what the skill does" }, "source": { - "type": "string", + "$ref": "#/definitions/SkillSource", "description": "Source location type (e.g., project, personal-copilot, plugin, builtin)" }, "userInvocable": { @@ -5561,6 +5676,7 @@ }, "host": { "type": "string", + "format": "uri", "description": "Authentication host URL" }, "login": { @@ -5870,6 +5986,18 @@ "description": "Path to remove from the client-provided session filesystem, with options for recursive removal and force.", "title": "SessionFsRmRequest" }, + "SessionFsSetProviderCapabilities": { + "type": "object", + "properties": { + "sqlite": { + "type": "boolean", + "description": "Whether the provider supports SQLite query/exists operations" + } + }, + "additionalProperties": false, + "description": "Optional capabilities declared by the provider", + "title": "SessionFsSetProviderCapabilities" + }, "SessionFsSetProviderConventions": { "type": "string", "enum": [ @@ -5893,6 +6021,10 @@ "conventions": { "$ref": "#/definitions/SessionFsSetProviderConventions", "description": "Path conventions used by this filesystem" + }, + "capabilities": { + "$ref": "#/definitions/SessionFsSetProviderCapabilities", + "description": "Optional capabilities declared by the provider" } }, "required": [ @@ -5919,6 +6051,103 @@ "description": "Indicates whether the calling client was registered as the session filesystem provider.", "title": "SessionFsSetProviderResult" }, + "SessionFsSqliteExistsResult": { + "type": "object", + "properties": { + "exists": { + "type": "boolean", + "description": "Whether the session database already exists" + } + }, + "required": [ + "exists" + ], + "additionalProperties": false, + "description": "Indicates whether the per-session SQLite database already exists.", + "title": "SessionFsSqliteExistsResult" + }, + "SessionFsSqliteQueryRequest": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "SQL query to execute" + }, + "queryType": { + "$ref": "#/definitions/SessionFsSqliteQueryType", + "description": "How to execute the query: 'exec' for DDL/multi-statement (no results), 'query' for SELECT (returns rows), 'run' for INSERT/UPDATE/DELETE (returns rowsAffected)" + }, + "params": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "null" + ] + }, + "description": "Optional named bind parameters" + } + }, + "required": [ + "query", + "queryType" + ], + "additionalProperties": false, + "description": "SQL query, query type, and optional bind parameters for executing a SQLite query against the per-session database.", + "title": "SessionFsSqliteQueryRequest" + }, + "SessionFsSqliteQueryResult": { + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + }, + "description": "For SELECT: array of row objects. For others: empty array." + }, + "columns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Column names from the result set" + }, + "rowsAffected": { + "type": "integer", + "minimum": 0, + "description": "Number of rows affected (for INSERT/UPDATE/DELETE)" + }, + "lastInsertRowid": { + "type": "number", + "description": "Last inserted row ID (for INSERT)" + }, + "error": { + "$ref": "#/definitions/SessionFsError", + "description": "Describes a filesystem error." + } + }, + "required": [ + "rows", + "columns", + "rowsAffected" + ], + "additionalProperties": false, + "description": "Query results including rows, columns, and rows affected, or a filesystem error if execution failed.", + "title": "SessionFsSqliteQueryResult" + }, + "SessionFsSqliteQueryType": { + "type": "string", + "enum": [ + "exec", + "query", + "run" + ], + "description": "How to execute the query: 'exec' for DDL/multi-statement (no results), 'query' for SELECT (returns rows), 'run' for INSERT/UPDATE/DELETE (returns rowsAffected)", + "title": "SessionFsSqliteQueryType" + }, "SessionFsStatRequest": { "type": "object", "properties": { @@ -6018,7 +6247,7 @@ "plan", "autopilot" ], - "description": "The agent mode. Valid values: \"interactive\", \"plan\", \"autopilot\".", + "description": "The session mode the agent is operating in", "title": "SessionMode" }, "SessionsForkRequest": { @@ -6159,8 +6388,8 @@ "description": "Description of what the skill does" }, "source": { - "type": "string", - "description": "Source location type (e.g., project, personal, plugin)" + "$ref": "#/definitions/SkillSource", + "description": "Source location type (e.g., project, personal-copilot, plugin, builtin)" }, "userInvocable": { "type": "boolean", @@ -6300,15 +6529,19 @@ "description": "Diagnostics from reloading skill definitions, with warnings and errors as separate lists.", "title": "SkillsLoadDiagnostics" }, - "SlashCommandAgentPromptMode": { + "SkillSource": { "type": "string", "enum": [ - "interactive", - "plan", - "autopilot" + "project", + "inherited", + "personal-copilot", + "personal-agents", + "plugin", + "custom", + "builtin" ], - "description": "Optional target session mode", - "title": "SlashCommandAgentPromptMode" + "description": "Source location type (e.g., project, personal-copilot, plugin, builtin)", + "title": "SkillSource" }, "SlashCommandAgentPromptResult": { "type": "object", @@ -6327,8 +6560,8 @@ "description": "Prompt text to display to the user" }, "mode": { - "$ref": "#/definitions/SlashCommandAgentPromptMode", - "description": "Optional target session mode" + "$ref": "#/definitions/SessionMode", + "description": "Optional target session mode for the agent prompt" }, "runtimeSettingsChanged": { "type": "boolean", @@ -6527,7 +6760,7 @@ "description": "Short description of the task" }, "status": { - "$ref": "#/definitions/TaskAgentInfoStatus", + "$ref": "#/definitions/TaskStatus", "description": "Current lifecycle status of the task" }, "startedAt": { @@ -6571,8 +6804,8 @@ "description": "Model used for the task when specified" }, "executionMode": { - "$ref": "#/definitions/TaskAgentInfoExecutionMode", - "description": "How the agent is currently being managed by the runtime" + "$ref": "#/definitions/TaskExecutionMode", + "description": "Whether task execution is synchronously awaited or managed in the background" }, "canPromoteToBackground": { "type": "boolean", @@ -6602,26 +6835,14 @@ "title": "TaskAgentInfo", "description": "Schema for the `TaskAgentInfo` type." }, - "TaskAgentInfoExecutionMode": { + "TaskExecutionMode": { "type": "string", "enum": [ "sync", "background" ], - "description": "How the agent is currently being managed by the runtime", - "title": "TaskAgentInfoExecutionMode" - }, - "TaskAgentInfoStatus": { - "type": "string", - "enum": [ - "running", - "idle", - "completed", - "failed", - "cancelled" - ], - "description": "Current lifecycle status of the task", - "title": "TaskAgentInfoStatus" + "description": "Whether task execution is synchronously awaited or managed in the background", + "title": "TaskExecutionMode" }, "TaskInfo": { "anyOf": [ @@ -6700,7 +6921,7 @@ "description": "Short description of the task" }, "status": { - "$ref": "#/definitions/TaskShellInfoStatus", + "$ref": "#/definitions/TaskStatus", "description": "Current lifecycle status of the task" }, "startedAt": { @@ -6722,8 +6943,8 @@ "description": "Whether the shell runs inside a managed PTY session or as an independent background process" }, "executionMode": { - "$ref": "#/definitions/TaskShellInfoExecutionMode", - "description": "Whether the shell command is currently sync-waited or background-managed" + "$ref": "#/definitions/TaskExecutionMode", + "description": "Whether task execution is synchronously awaited or managed in the background" }, "canPromoteToBackground": { "type": "boolean", @@ -6760,27 +6981,6 @@ "description": "Whether the shell runs inside a managed PTY session or as an independent background process", "title": "TaskShellInfoAttachmentMode" }, - "TaskShellInfoExecutionMode": { - "type": "string", - "enum": [ - "sync", - "background" - ], - "description": "Whether the shell command is currently sync-waited or background-managed", - "title": "TaskShellInfoExecutionMode" - }, - "TaskShellInfoStatus": { - "type": "string", - "enum": [ - "running", - "idle", - "completed", - "failed", - "cancelled" - ], - "description": "Current lifecycle status of the task", - "title": "TaskShellInfoStatus" - }, "TasksPromoteToBackgroundRequest": { "type": "object", "properties": { @@ -6932,6 +7132,18 @@ "description": "Identifier assigned to the newly started background agent task.", "title": "TasksStartAgentResult" }, + "TaskStatus": { + "type": "string", + "enum": [ + "running", + "idle", + "completed", + "failed", + "cancelled" + ], + "description": "Current lifecycle status of the task", + "title": "TaskStatus" + }, "Tool": { "type": "object", "properties": { diff --git a/schemas/session-events.schema.json b/schemas/session-events.schema.json index 37ff396..d908951 100644 --- a/schemas/session-events.schema.json +++ b/schemas/session-events.schema.json @@ -955,15 +955,18 @@ }, "duration": { "type": "number", - "description": "Duration of the API call in milliseconds" + "description": "Duration of the API call in milliseconds", + "format": "duration" }, "ttftMs": { "type": "number", - "description": "Time to first token in milliseconds. Only available for streaming requests" + "description": "Time to first token in milliseconds. Only available for streaming requests", + "format": "duration" }, "interTokenLatencyMs": { "type": "number", - "description": "Average inter-token latency in milliseconds. Only available for streaming requests" + "description": "Average inter-token latency in milliseconds. Only available for streaming requests", + "format": "duration" }, "initiator": { "type": "string", @@ -999,7 +1002,7 @@ }, "reasoningEffort": { "type": "string", - "description": "Reasoning effort level used for model calls, if applicable (e.g. \"none\", \"low\", \"medium\", \"high\", \"xhigh\")" + "description": "Reasoning effort level used for model calls, if applicable (e.g. \"none\", \"low\", \"medium\", \"high\", \"xhigh\", \"max\")" } }, "required": [ @@ -1123,8 +1126,8 @@ "description": "Request ID of the resolved request; clients should dismiss any UI for this request" }, "response": { - "type": "string", - "description": "The user's choice: 'yes', 'yes_always', or 'no'" + "$ref": "#/definitions/AutoModeSwitchResponse", + "description": "The user's auto-mode-switch choice" } }, "required": [ @@ -1270,6 +1273,16 @@ "description": "Session event \"auto_mode_switch.requested\". Auto mode switch request notification requiring user approval", "title": "AutoModeSwitchRequestedEvent" }, + "AutoModeSwitchResponse": { + "type": "string", + "enum": [ + "yes", + "yes_always", + "no" + ], + "description": "The user's auto-mode-switch choice", + "title": "AutoModeSwitchResponse" + }, "BackgroundTasksChangedData": { "type": "object", "properties": {}, @@ -1764,7 +1777,8 @@ }, "duration": { "type": "number", - "description": "Duration of the compaction LLM call in milliseconds" + "description": "Duration of the compaction LLM call in milliseconds", + "format": "duration" }, "model": { "type": "string", @@ -2752,6 +2766,17 @@ "description": "Session event \"session.error\". Error details for timeline display including message and optional diagnostic information", "title": "ErrorEvent" }, + "ExitPlanModeAction": { + "type": "string", + "enum": [ + "exit_only", + "interactive", + "autopilot", + "autopilot_fleet" + ], + "description": "Exit plan mode action", + "title": "ExitPlanModeAction" + }, "ExitPlanModeCompletedData": { "type": "object", "properties": { @@ -2764,8 +2789,8 @@ "description": "Whether the plan was approved by the user" }, "selectedAction": { - "type": "string", - "description": "Which action the user selected (e.g. 'autopilot', 'interactive', 'exit_only')" + "$ref": "#/definitions/ExitPlanModeAction", + "description": "Action selected by the user" }, "autoApproveEdits": { "type": "boolean", @@ -2857,13 +2882,14 @@ "actions": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/ExitPlanModeAction", + "description": "Exit plan mode action" }, - "description": "Available actions the user can take (e.g., approve, edit, reject)" + "description": "Available actions the user can take" }, "recommendedAction": { - "type": "string", - "description": "The recommended action for the user to take" + "$ref": "#/definitions/ExitPlanModeAction", + "description": "Recommended action to preselect for the user" } }, "required": [ @@ -3252,6 +3278,7 @@ }, "host": { "type": "string", + "format": "uri", "description": "GitHub host URL for the source session (e.g., https://github.com or https://tenant.ghe.com)" } }, @@ -3767,6 +3794,7 @@ }, "serverUrl": { "type": "string", + "format": "uri", "description": "URL of the MCP server that requires OAuth" }, "staticClientConfig": { @@ -3945,11 +3973,11 @@ "description": "Server name (config key)" }, "status": { - "$ref": "#/definitions/McpServersLoadedServerStatus", + "$ref": "#/definitions/McpServerStatus", "description": "Connection status: connected, failed, needs-auth, pending, disabled, or not_configured" }, "source": { - "type": "string", + "$ref": "#/definitions/McpServerSource", "description": "Configuration source: user, workspace, plugin, or builtin" }, "error": { @@ -3965,7 +3993,18 @@ "title": "McpServersLoadedServer", "description": "Schema for the `McpServersLoadedServer` type." }, - "McpServersLoadedServerStatus": { + "McpServerSource": { + "type": "string", + "enum": [ + "user", + "workspace", + "plugin", + "builtin" + ], + "description": "Configuration source: user, workspace, plugin, or builtin", + "title": "McpServerSource" + }, + "McpServerStatus": { "type": "string", "enum": [ "connected", @@ -3976,7 +4015,7 @@ "not_configured" ], "description": "Connection status: connected, failed, needs-auth, pending, disabled, or not_configured", - "title": "McpServersLoadedServerStatus" + "title": "McpServerStatus" }, "McpServerStatusChangedData": { "type": "object", @@ -3986,8 +4025,8 @@ "description": "Name of the MCP server whose status changed" }, "status": { - "$ref": "#/definitions/McpServerStatusChangedStatus", - "description": "New connection status: connected, failed, needs-auth, pending, disabled, or not_configured" + "$ref": "#/definitions/McpServerStatus", + "description": "Connection status: connected, failed, needs-auth, pending, disabled, or not_configured" } }, "required": [ @@ -4054,29 +4093,16 @@ "description": "Session event \"session.mcp_server_status_changed\".", "title": "McpServerStatusChangedEvent" }, - "McpServerStatusChangedStatus": { - "type": "string", - "enum": [ - "connected", - "failed", - "needs-auth", - "pending", - "disabled", - "not_configured" - ], - "description": "New connection status: connected, failed, needs-auth, pending, disabled, or not_configured", - "title": "McpServerStatusChangedStatus" - }, "ModeChangedData": { "type": "object", "properties": { "previousMode": { - "type": "string", - "description": "Agent mode before the change (e.g., \"interactive\", \"plan\", \"autopilot\")" + "$ref": "#/definitions/SessionMode", + "description": "The session mode the agent is operating in" }, "newMode": { - "type": "string", - "description": "Agent mode after the change (e.g., \"interactive\", \"plan\", \"autopilot\")" + "$ref": "#/definitions/SessionMode", + "description": "The session mode the agent is operating in" } }, "required": [ @@ -4166,7 +4192,8 @@ }, "durationMs": { "type": "number", - "description": "Duration of the failed API call in milliseconds" + "description": "Duration of the failed API call in milliseconds", + "format": "duration" }, "source": { "$ref": "#/definitions/ModelCallFailureSource", @@ -4965,7 +4992,7 @@ "description": "Tool call ID that triggered this permission request" }, "action": { - "$ref": "#/definitions/PermissionPromptRequestMemoryAction", + "$ref": "#/definitions/PermissionRequestMemoryAction", "description": "Whether this is a store or vote memory operation" }, "subject": { @@ -4981,7 +5008,7 @@ "description": "Source references for the stored fact (store only)" }, "direction": { - "$ref": "#/definitions/PermissionPromptRequestMemoryDirection", + "$ref": "#/definitions/PermissionRequestMemoryDirection", "description": "Vote direction (vote only)" }, "reason": { @@ -4997,25 +5024,6 @@ "description": "Memory operation permission prompt", "title": "PermissionPromptRequestMemory" }, - "PermissionPromptRequestMemoryAction": { - "type": "string", - "enum": [ - "store", - "vote" - ], - "default": "store", - "description": "Whether this is a store or vote memory operation", - "title": "PermissionPromptRequestMemoryAction" - }, - "PermissionPromptRequestMemoryDirection": { - "type": "string", - "enum": [ - "upvote", - "downvote" - ], - "description": "Vote direction (vote only)", - "title": "PermissionPromptRequestMemoryDirection" - }, "PermissionPromptRequestPath": { "type": "object", "properties": { @@ -5107,6 +5115,7 @@ }, "url": { "type": "string", + "format": "uri", "description": "URL to be fetched" } }, @@ -5512,8 +5521,8 @@ "store", "vote" ], - "default": "store", "description": "Whether this is a store or vote memory operation", + "default": "store", "title": "PermissionRequestMemoryAction" }, "PermissionRequestMemoryDirection": { @@ -5648,6 +5657,7 @@ "properties": { "url": { "type": "string", + "format": "uri", "description": "URL that may be accessed by the command" } }, @@ -5676,6 +5686,7 @@ }, "url": { "type": "string", + "format": "uri", "description": "URL to be fetched" } }, @@ -5964,7 +5975,7 @@ }, "reasoningEffort": { "type": "string", - "description": "Reasoning effort level used for model calls, if applicable (e.g. \"none\", \"low\", \"medium\", \"high\", \"xhigh\")" + "description": "Reasoning effort level used for model calls, if applicable (e.g. \"none\", \"low\", \"medium\", \"high\", \"xhigh\", \"max\")" }, "reasoningSummary": { "$ref": "#/definitions/ReasoningSummary", @@ -6289,7 +6300,8 @@ "intervalMs": { "type": "integer", "exclusiveMinimum": 0, - "description": "Interval between ticks in milliseconds" + "description": "Interval between ticks in milliseconds", + "format": "duration" }, "prompt": { "type": "string", @@ -6696,6 +6708,16 @@ ], "description": "Union of all session event variants emitted by the Copilot CLI runtime." }, + "SessionMode": { + "type": "string", + "enum": [ + "interactive", + "plan", + "autopilot" + ], + "description": "The session mode the agent is operating in", + "title": "SessionMode" + }, "ShutdownCodeChanges": { "type": "object", "properties": { @@ -6752,7 +6774,8 @@ }, "totalApiDurationMs": { "type": "number", - "description": "Cumulative time spent in API calls during the session, in milliseconds" + "description": "Cumulative time spent in API calls during the session, in milliseconds", + "format": "duration" }, "sessionStartTime": { "type": "number", @@ -7164,8 +7187,8 @@ "description": "Description of what the skill does" }, "source": { - "type": "string", - "description": "Source location type of the skill (e.g., project, personal, plugin)" + "$ref": "#/definitions/SkillSource", + "description": "Source location type (e.g., project, personal-copilot, plugin, builtin)" }, "userInvocable": { "type": "boolean", @@ -7191,6 +7214,20 @@ "title": "SkillsLoadedSkill", "description": "Schema for the `SkillsLoadedSkill` type." }, + "SkillSource": { + "type": "string", + "enum": [ + "project", + "inherited", + "personal-copilot", + "personal-agents", + "plugin", + "custom", + "builtin" + ], + "description": "Source location type (e.g., project, personal-copilot, plugin, builtin)", + "title": "SkillSource" + }, "SnapshotRewindData": { "type": "object", "properties": { @@ -7297,7 +7334,7 @@ }, "reasoningEffort": { "type": "string", - "description": "Reasoning effort level used for model calls, if applicable (e.g. \"none\", \"low\", \"medium\", \"high\", \"xhigh\")" + "description": "Reasoning effort level used for model calls, if applicable (e.g. \"none\", \"low\", \"medium\", \"high\", \"xhigh\", \"max\")" }, "reasoningSummary": { "$ref": "#/definitions/ReasoningSummary", @@ -7414,7 +7451,8 @@ }, "durationMs": { "type": "number", - "description": "Wall-clock duration of the sub-agent execution in milliseconds" + "description": "Wall-clock duration of the sub-agent execution in milliseconds", + "format": "duration" } }, "required": [ @@ -7574,7 +7612,8 @@ }, "durationMs": { "type": "number", - "description": "Wall-clock duration of the sub-agent execution in milliseconds" + "description": "Wall-clock duration of the sub-agent execution in milliseconds", + "format": "duration" } }, "required": [ @@ -9714,6 +9753,7 @@ }, "url": { "type": "string", + "format": "uri", "description": "URL to the referenced item on GitHub" } }, diff --git a/src/github/copilot_sdk/client.clj b/src/github/copilot_sdk/client.clj index c94d5df..970b09f 100644 --- a/src/github/copilot_sdk/client.clj +++ b/src/github/copilot_sdk/client.clj @@ -702,11 +702,12 @@ {:result (session/handle-system-message-transform client session-id sections)})) - ;; SessionFs operations (upstream PR #917) + ;; SessionFs operations (upstream PR #917, sqlite added in #1299) ("sessionFs.readFile" "sessionFs.writeFile" "sessionFs.appendFile" "sessionFs.exists" "sessionFs.stat" "sessionFs.mkdir" "sessionFs.readdir" "sessionFs.readdirWithTypes" - "sessionFs.rm" "sessionFs.rename") + "sessionFs.rm" "sessionFs.rename" + "sessionFs.sqliteQuery" "sessionFs.sqliteExists") (let [{:keys [session-id]} params] (if-not (get-in @(:state client) [:sessions session-id]) {:error {:code -32001 :message (str "Unknown session: " session-id)}} @@ -962,9 +963,12 @@ (when-let [sf-config (:session-fs client)] (let [{:keys [connection-io]} @(:state client)] (proto/send-request! connection-io "sessionFs.setProvider" - {:initial-cwd (:initial-cwd sf-config) - :session-state-path (:session-state-path sf-config) - :conventions (:conventions sf-config)}))) + (cond-> {:initial-cwd (:initial-cwd sf-config) + :session-state-path (:session-state-path sf-config) + :conventions (:conventions sf-config)} + ;; Upstream PR #1299: forward provider capabilities (e.g., {:sqlite true}). + (:capabilities sf-config) + (assoc :capabilities (:capabilities sf-config)))))) ;; Set up notification routing and request handling (start-notification-router! client) @@ -2079,9 +2083,11 @@ (when-let [sf-config (:session-fs client)] (let [{:keys [connection-io]} @(:state client)] (proto/send-request! connection-io "sessionFs.setProvider" - {:initial-cwd (:initial-cwd sf-config) - :session-state-path (:session-state-path sf-config) - :conventions (:conventions sf-config)}))) + (cond-> {:initial-cwd (:initial-cwd sf-config) + :session-state-path (:session-state-path sf-config) + :conventions (:conventions sf-config)} + (:capabilities sf-config) + (assoc :capabilities (:capabilities sf-config)))))) (start-notification-router! client) (setup-request-handler! client) (swap! (:state client) assoc :status :connected) diff --git a/src/github/copilot_sdk/generated/event_specs.clj b/src/github/copilot_sdk/generated/event_specs.clj index 4e5fbf3..7a283be 100644 --- a/src/github/copilot_sdk/generated/event_specs.clj +++ b/src/github/copilot_sdk/generated/event_specs.clj @@ -7,7 +7,7 @@ (s/def :github.copilot-sdk.generated.event-specs/action #{"cancel" "accept" "decline"}) -(s/def :github.copilot-sdk.generated.event-specs/actions (s/coll-of clojure.core/string?)) +(s/def :github.copilot-sdk.generated.event-specs/actions (s/coll-of #{"interactive" "autopilot_fleet" "exit_only" "autopilot"})) (s/def :github.copilot-sdk.generated.event-specs/agent-description clojure.core/string?) @@ -203,7 +203,7 @@ (s/def :github.copilot-sdk.generated.event-specs/native-document-path-fallback-paths (s/coll-of clojure.core/string?)) -(s/def :github.copilot-sdk.generated.event-specs/new-mode clojure.core/string?) +(s/def :github.copilot-sdk.generated.event-specs/new-mode #{"interactive" "autopilot" "plan"}) (s/def :github.copilot-sdk.generated.event-specs/new-model clojure.core/string?) @@ -251,7 +251,7 @@ (s/def :github.copilot-sdk.generated.event-specs/pre-truncation-tokens-in-messages clojure.core/number?) -(s/def :github.copilot-sdk.generated.event-specs/previous-mode clojure.core/string?) +(s/def :github.copilot-sdk.generated.event-specs/previous-mode #{"interactive" "autopilot" "plan"}) (s/def :github.copilot-sdk.generated.event-specs/previous-model clojure.core/string?) @@ -287,7 +287,7 @@ (s/def :github.copilot-sdk.generated.event-specs/reasoning-tokens clojure.core/number?) -(s/def :github.copilot-sdk.generated.event-specs/recommended-action clojure.core/string?) +(s/def :github.copilot-sdk.generated.event-specs/recommended-action #{"interactive" "autopilot_fleet" "exit_only" "autopilot"}) (s/def :github.copilot-sdk.generated.event-specs/recurring clojure.core/boolean?) @@ -305,7 +305,7 @@ (s/def :github.copilot-sdk.generated.event-specs/resolved-by-hook clojure.core/boolean?) -(s/def :github.copilot-sdk.generated.event-specs/response clojure.core/string?) +(s/def :github.copilot-sdk.generated.event-specs/response #{"yes_always" "yes" "no"}) (s/def :github.copilot-sdk.generated.event-specs/result (s/spec (fn [v947] (or (s/valid? clojure.core/map? v947) (s/valid? (s/or :branch-0 clojure.core/map? :branch-1 clojure.core/map? :branch-2 clojure.core/map? :branch-3 clojure.core/map? :branch-4 clojure.core/map? :branch-5 clojure.core/map? :branch-6 clojure.core/map? :branch-7 clojure.core/map? :branch-8 clojure.core/map?) v947))))) @@ -315,7 +315,7 @@ (s/def :github.copilot-sdk.generated.event-specs/role #{"developer" "system"}) -(s/def :github.copilot-sdk.generated.event-specs/selected-action clojure.core/string?) +(s/def :github.copilot-sdk.generated.event-specs/selected-action #{"interactive" "autopilot_fleet" "exit_only" "autopilot"}) (s/def :github.copilot-sdk.generated.event-specs/selected-model clojure.core/string?) diff --git a/src/github/copilot_sdk/protocol.clj b/src/github/copilot_sdk/protocol.clj index 4795154..57ccf3d 100644 --- a/src/github/copilot_sdk/protocol.clj +++ b/src/github/copilot_sdk/protocol.clj @@ -144,6 +144,26 @@ (put! ch {:result (:result msg)}) (close! ch)))))) +(defn- preserve-outgoing-opaque-fields + "Per-method outgoing escape hatch: after recursive kebab→camelCase + conversion via `util/clj->wire`, restore opaque user-supplied values + that must round-trip verbatim (e.g. SQL column names in + `sessionFs.sqliteQuery` result rows). Without this, a provider + returning `{:rows [{:user_id 1}]}` would be serialized as + `{:userId 1}`, producing rows that no longer match the `columns` + array." + [method raw-result wire-result] + (cond + ;; Upstream PR #1299: SQL row column names are opaque identifiers. + ;; Preserve the original :rows vector verbatim while keeping the + ;; sibling SDK fields (rows-affected → rowsAffected, etc.) converted. + (and (= "sessionFs.sqliteQuery" method) + (map? raw-result) + (contains? raw-result :rows)) + (assoc wire-result :rows (:rows raw-result)) + + :else wire-result)) + (defn- handle-request! "Handle an incoming request message (e.g., tool.call). Sends response via outgoing-ch." [state-atom outgoing-ch msg] @@ -163,7 +183,11 @@ (>! outgoing-ch {:jsonrpc "2.0" :id id :error (util/clj->wire (:error result))})) (do (log/debug "Request success response for id=" id) - (>! outgoing-ch {:jsonrpc "2.0" :id id :result (util/clj->wire (:result result))})))) + (>! outgoing-ch {:jsonrpc "2.0" :id id + :result (preserve-outgoing-opaque-fields + method + (:result result) + (util/clj->wire (:result result)))})))) (catch Exception e (log/error "Request handler exception: " (ex-message e)) (>! outgoing-ch {:jsonrpc "2.0" @@ -212,6 +236,12 @@ (and (= "tool.call" method) (map? params) (contains? params :arguments)) (assoc-in converted [:params :arguments] (:arguments params)) + ;; Upstream PR #1299: SQL bind parameters are opaque keyed values + ;; (e.g. `$user_id`). Preserve the raw map so kebab-case conversion + ;; doesn't mangle placeholder names before the handler binds them. + (and (= "sessionFs.sqliteQuery" method) (map? params) (contains? params :params)) + (assoc-in converted [:params :params] (:params params)) + ;; v3: preserve raw arguments / subject / payload in broadcast events (and (= "session.event" method) (map? (:event params))) diff --git a/src/github/copilot_sdk/session.clj b/src/github/copilot_sdk/session.clj index 623a9cc..87d2abd 100644 --- a/src/github/copilot_sdk/session.clj +++ b/src/github/copilot_sdk/session.clj @@ -153,10 +153,21 @@ (defn set-session-fs-handler! "Store a sessionFs handler map on a session. Called by client during create/resume - when :session-fs is enabled. Handler is a map of keyword→fn for FS operations." + when :session-fs is enabled. Handler is a map of keyword→fn for FS operations. + + Upstream PR #1299: when the client's :session-fs config declares + `:capabilities {:sqlite true}` but the per-session handler does not expose + :sqlite-query, throw to prevent silent SQL dispatch failures at runtime." [client session-id handler] - (swap! (:state client) assoc-in [:sessions session-id :session-fs-handler] - (validate-session-fs-handler! handler {:session-id session-id}))) + (let [validated (validate-session-fs-handler! handler {:session-id session-id}) + sqlite-declared? (boolean (get-in client [:session-fs :capabilities :sqlite])) + handler-has-sqlite? (boolean (:sqlite-query validated))] + (when (and sqlite-declared? (not handler-has-sqlite?)) + (throw (ex-info + "SessionFs config declares capabilities.sqlite but the provider does not implement sqlite." + {:session-id session-id + :capabilities (get-in client [:session-fs :capabilities])}))) + (swap! (:state client) assoc-in [:sessions session-id :session-fs-handler] validated))) (defn- channel? "Check if x is a core.async channel." @@ -235,86 +246,105 @@ preserved by the client registration path; this helper is for provider-style maps." [provider] - (let [provider (validate-session-fs-provider! provider {:contract :session-fs-provider})] - {:read-file - (fn [{:keys [path]}] - (try - (let [result (await-session-fs-result ((:read-file provider) path))] - (if (and (map? result) - (or (contains? result :content) - (contains? result :error))) - result - {:content result})) - (catch Throwable t - {:content "" :error (session-fs-error t)}))) - - :write-file - (fn [{:keys [path content mode] :as params}] - (session-fs-void-result (:write-file provider) [path content mode] params)) - - :append-file - (fn [{:keys [path content mode] :as params}] - (session-fs-void-result (:append-file provider) [path content mode] params)) - - :exists - (fn [{:keys [path]}] - (try - (let [result (await-session-fs-result ((:exists provider) path))] - (if (and (map? result) - (or (contains? result :exists) - (contains? result :error))) - result - {:exists (boolean result)})) - (catch Throwable _ - {:exists false}))) - - :stat - (fn [{:keys [path]}] - (try - (await-session-fs-result ((:stat provider) path)) - (catch Throwable t - {:is-file false - :is-directory false - :size 0 - :mtime (.toString (java.time.Instant/now)) - :birthtime (.toString (java.time.Instant/now)) - :error (session-fs-error t)}))) - - :mkdir - (fn [{:keys [path recursive mode] :as params}] - (session-fs-void-result (:mkdir provider) [path (boolean recursive) mode] params)) - - :readdir - (fn [{:keys [path]}] - (try - (let [result (await-session-fs-result ((:readdir provider) path))] - (if (and (map? result) - (or (contains? result :entries) - (contains? result :error))) - result - {:entries result})) - (catch Throwable t - {:entries [] :error (session-fs-error t)}))) - - :readdir-with-types - (fn [{:keys [path]}] - (try - (let [result (await-session-fs-result ((:readdir-with-types provider) path))] - (if (and (map? result) - (or (contains? result :entries) - (contains? result :error))) - result - {:entries result})) - (catch Throwable t - {:entries [] :error (session-fs-error t)}))) - - :rm - (fn [{:keys [path recursive force] :as params}] - (session-fs-void-result (:rm provider) [path (boolean recursive) (boolean force)] params)) - - :rename - (fn [{:keys [src dest] :as params}] - (session-fs-void-result (:rename provider) [src dest] params))})) + (let [provider (validate-session-fs-provider! provider {:contract :session-fs-provider}) + base-handler + {:read-file + (fn [{:keys [path]}] + (try + (let [result (await-session-fs-result ((:read-file provider) path))] + (if (and (map? result) + (or (contains? result :content) + (contains? result :error))) + result + {:content result})) + (catch Throwable t + {:content "" :error (session-fs-error t)}))) + + :write-file + (fn [{:keys [path content mode] :as params}] + (session-fs-void-result (:write-file provider) [path content mode] params)) + + :append-file + (fn [{:keys [path content mode] :as params}] + (session-fs-void-result (:append-file provider) [path content mode] params)) + + :exists + (fn [{:keys [path]}] + (try + (let [result (await-session-fs-result ((:exists provider) path))] + (if (and (map? result) + (or (contains? result :exists) + (contains? result :error))) + result + {:exists (boolean result)})) + (catch Throwable _ + {:exists false}))) + + :stat + (fn [{:keys [path]}] + (try + (await-session-fs-result ((:stat provider) path)) + (catch Throwable t + {:is-file false + :is-directory false + :size 0 + :mtime (.toString (java.time.Instant/now)) + :birthtime (.toString (java.time.Instant/now)) + :error (session-fs-error t)}))) + + :mkdir + (fn [{:keys [path recursive mode] :as params}] + (session-fs-void-result (:mkdir provider) [path (boolean recursive) mode] params)) + + :readdir + (fn [{:keys [path]}] + (try + (let [result (await-session-fs-result ((:readdir provider) path))] + (if (and (map? result) + (or (contains? result :entries) + (contains? result :error))) + result + {:entries result})) + (catch Throwable t + {:entries [] :error (session-fs-error t)}))) + + :readdir-with-types + (fn [{:keys [path]}] + (try + (let [result (await-session-fs-result ((:readdir-with-types provider) path))] + (if (and (map? result) + (or (contains? result :entries) + (contains? result :error))) + result + {:entries result})) + (catch Throwable t + {:entries [] :error (session-fs-error t)}))) + + :rm + (fn [{:keys [path recursive force] :as params}] + (session-fs-void-result (:rm provider) [path (boolean recursive) (boolean force)] params)) + + :rename + (fn [{:keys [src dest] :as params}] + (session-fs-void-result (:rename provider) [src dest] params))}] + ;; Upstream PR #1299: optional SQLite sub-provider. Adapter exposes flat + ;; :sqlite-query and :sqlite-exists handler keys that the RPC dispatch + ;; layer wires to the per-session handler map. + ;; + ;; Unlike the FS methods, SQLite handlers let provider exceptions propagate + ;; (matching upstream Node behavior). The dispatch layer in + ;; handle-session-fs-request! converts these into JSON-RPC errors. + (if-let [sql (:sqlite provider)] + (assoc base-handler + :sqlite-query + (fn [{:keys [query-type query params]}] + (let [result (await-session-fs-result ((:query sql) query-type query params))] + (or result {:rows [] :columns [] :rows-affected 0}))) + + :sqlite-exists + (fn [_params] + {:exists (boolean (await-session-fs-result ((:exists sql))))})) + base-handler))) (defn adapt-session-fs-handler "Return an RPC-shaped sessionFs handler for either supported factory contract. @@ -328,7 +358,13 @@ (accepts-arity? (:append-file handler-or-provider) 3) (accepts-arity? (:mkdir handler-or-provider) 3) (accepts-arity? (:rm handler-or-provider) 3) - (accepts-arity? (:rename handler-or-provider) 2)) + (accepts-arity? (:rename handler-or-provider) 2) + ;; PR #1299: presence of nested :sqlite provider also indicates + ;; provider-style (low-level handlers expose flat :sqlite-query / + ;; :sqlite-exists keys instead). + (and (map? (:sqlite handler-or-provider)) + (or (contains? (:sqlite handler-or-provider) :query) + (contains? (:sqlite handler-or-provider) :exists)))) (create-session-fs-adapter handler-or-provider) handler-or-provider)) @@ -425,16 +461,36 @@ "sessionFs.readdir" :readdir "sessionFs.readdirWithTypes" :readdir-with-types "sessionFs.rm" :rm - "sessionFs.rename" :rename}) + "sessionFs.rename" :rename + ;; Upstream PR #1299: SQLite operations. Handlers are optional — provider + ;; opts in by exposing a nested :sqlite {:query :exists} sub-provider. + "sessionFs.sqliteQuery" :sqlite-query + "sessionFs.sqliteExists" :sqlite-exists}) + +(defn- coerce-sqlite-params + "For sessionFs.sqliteQuery: coerce the wire-format params into the shape + expected by adapted handlers. The wire `queryType` is a literal string — + convert to keyword so handlers receive `:exec`, `:query`, or `:run`." + [method params] + (if (= method "sessionFs.sqliteQuery") + (cond-> params + (string? (:query-type params)) + (update :query-type keyword)) + params)) (defn handle-session-fs-request! "Handle an incoming sessionFs.* RPC request. Dispatches to the session's - FS handler and returns a channel with {:result ...} or {:error ...}." + FS handler and returns a channel with {:result ...} or {:error ...}. + + For sessionFs.sqliteQuery / sqliteExists (upstream PR #1299), exceptions + from the provider propagate as JSON-RPC errors rather than being wrapped + in a SessionFsError result map, matching upstream Node behavior." [client session-id method params] (async/thread-call (fn [] (let [handler-map (:session-fs-handler (session-state client session-id)) - handler-key (session-fs-method->handler-key method)] + handler-key (session-fs-method->handler-key method) + params (coerce-sqlite-params method params)] (if-not handler-map {:error {:code -32001 :message (str "No sessionFs handler for session: " session-id)}} (if-let [handler-fn (get handler-map handler-key)] diff --git a/src/github/copilot_sdk/specs.clj b/src/github/copilot_sdk/specs.clj index a2c0aaf..085597b 100644 --- a/src/github/copilot_sdk/specs.clj +++ b/src/github/copilot_sdk/specs.clj @@ -79,8 +79,23 @@ (s/def ::initial-cwd ::non-blank-string) (s/def ::session-state-path ::non-blank-string) (s/def ::conventions #{"windows" "posix"}) + +;; Optional sessionFs provider capabilities advertised on sessionFs.setProvider. +;; Upstream PR #1299 (Node.js SDK v1.0.0-beta.4+): clients with sqlite-capable +;; providers set {:sqlite true} to advertise SQL query/exists support. +;; Kept as an open map so future capability flags pass through automatically. +;; We intentionally do NOT register a global ::capabilities spec since +;; ":capabilities" is reused in several unrelated contexts (session capabilities, +;; model capabilities); locally enforced via the predicate below. +(s/def ::session-fs-capabilities + (s/and map? + #(or (not (contains? % :sqlite)) (boolean? (:sqlite %))))) + (s/def ::session-fs - (s/keys :req-un [::initial-cwd ::session-state-path ::conventions])) + (s/and map? + #(let [caps (:capabilities %)] + (or (nil? caps) (s/valid? ::session-fs-capabilities caps))) + (s/keys :req-un [::initial-cwd ::session-state-path ::conventions]))) ;; SessionFs handler — map of keyword→fn for each FS operation. ;; Each fn receives a params map (with :session-id, :path, etc.) and returns a result map (or nil for void ops). @@ -95,9 +110,15 @@ (s/def ::rm fn?) (s/def ::rename fn?) +;; SQLite handler keys (upstream PR #1299). Optional — present only when the +;; provider opts in to SQLite support via nested :sqlite {:query :exists}. +(s/def ::sqlite-query fn?) +(s/def ::sqlite-exists fn?) + (s/def ::session-fs-handler (s/keys :req-un [::read-file ::write-file ::append-file ::exists ::stat - ::mkdir ::readdir ::readdir-with-types ::rm ::rename])) + ::mkdir ::readdir ::readdir-with-types ::rm ::rename] + :opt-un [::sqlite-query ::sqlite-exists])) (defn- fn-accepts-arity? [f n] @@ -130,11 +151,30 @@ (and (map? provider) (every? (fn [[operation arity]] (fn-accepts-arity? (get provider operation) arity)) - session-fs-provider-arities))) + session-fs-provider-arities) + ;; Optional :sqlite sub-provider (upstream PR #1299) — when present must + ;; be a map of {:query 3-arg-fn :exists 0-arg-fn}. + (let [sql (:sqlite provider)] + (or (nil? sql) + (and (map? sql) + (fn-accepts-arity? (:query sql) 3) + (fn-accepts-arity? (:exists sql) 0)))))) + +;; SQLite query type passed to the sqlite provider's :query function. +;; Wire form is the literal string; we expose idiomatic keywords to handlers. +(s/def ::sqlite-query-type #{:exec :query :run}) + +;; Shape returned by an adapted sqlite-query handler (and the wire shape after +;; clj->wire conversion). Mirrors generated SessionFsSqliteQueryResult. +(s/def ::sqlite-rows (s/coll-of map?)) +(s/def ::sqlite-columns (s/coll-of string?)) +(s/def ::rows-affected (s/and integer? #(<= 0 %))) +(s/def ::last-insert-rowid (s/or :number number? :string string?)) ;; Provider-style session filesystem implementation. Same operation keys as ;; ::session-fs-handler, but functions take direct positional arguments and ;; throw on errors instead of returning RPC-shaped success/error maps. +;; May optionally provide a :sqlite sub-provider for SQLite support. (s/def ::session-fs-provider (s/and ::session-fs-handler session-fs-provider?)) diff --git a/test/github/copilot_sdk/integration_test.clj b/test/github/copilot_sdk/integration_test.clj index 2712f0d..a5bf6cf 100644 --- a/test/github/copilot_sdk/integration_test.clj +++ b/test/github/copilot_sdk/integration_test.clj @@ -2632,6 +2632,244 @@ (sdk/wire` would convert + ;; `:user_id` → `:userId`, producing rows whose keys no longer match + ;; the `columns` array. Upstream Node forwards row maps verbatim. + (let [client-with-fs (assoc *test-client* :session-fs {:initial-cwd "/workspace" + :session-state-path "/state" + :conventions "posix" + :capabilities {:sqlite true}}) + session (sdk/create-session client-with-fs + {:on-permission-request sdk/approve-all + :create-session-fs-handler + (fn [_session] + {:read-file (fn [_] "x") + :write-file (fn [_ _ _] nil) + :append-file (fn [_ _ _] nil) + :exists (fn [_] true) + :stat (fn [_] {:is-file true :is-directory false :size 1 :mtime "x" :birthtime "x"}) + :mkdir (fn [_ _ _] nil) + :readdir (fn [_] []) + :readdir-with-types (fn [_] []) + :rm (fn [_ _ _] nil) + :rename (fn [_ _] nil) + :sqlite {:query (fn [_ _ _] + {:rows [{:user_id 1 :created_at "2026-01-01"} + {:user_id 2 :created_at "2026-01-02"}] + :columns ["user_id" "created_at"] + :rows-affected 0}) + :exists (fn [] true)}})}) + response (mock/send-rpc-request! *mock-server* + "sessionFs.sqliteQuery" + {:sessionId (sdk/session-id session) + :query "SELECT user_id, created_at FROM users" + :queryType "query"}) + result (:result response)] + ;; Row keys must round-trip verbatim + (is (= [{:user_id 1 :created_at "2026-01-01"} + {:user_id 2 :created_at "2026-01-02"}] + (:rows result))) + ;; Columns array (strings) is unchanged + (is (= ["user_id" "created_at"] (:columns result))) + ;; Sibling SDK fields still get kebab→camelCase converted + (is (= 0 (:rowsAffected result))))) + + (testing "sessionFs.sqliteExists RPC dispatches to handler" + (let [client-with-fs (assoc *test-client* :session-fs {:initial-cwd "/workspace" + :session-state-path "/state" + :conventions "posix" + :capabilities {:sqlite true}}) + session (sdk/create-session client-with-fs + {:on-permission-request sdk/approve-all + :create-session-fs-handler + (fn [_session] + {:read-file (fn [_] "x") + :write-file (fn [_ _ _] nil) + :append-file (fn [_ _ _] nil) + :exists (fn [_] true) + :stat (fn [_] {:is-file true :is-directory false :size 1 :mtime "x" :birthtime "x"}) + :mkdir (fn [_ _ _] nil) + :readdir (fn [_] []) + :readdir-with-types (fn [_] []) + :rm (fn [_ _ _] nil) + :rename (fn [_ _] nil) + :sqlite {:query (fn [_ _ _] {:rows [] :columns [] :rows-affected 0}) + :exists (fn [] true)}})}) + response (mock/send-rpc-request! *mock-server* + "sessionFs.sqliteExists" + {:sessionId (sdk/session-id session)})] + (is (= {:exists true} (:result response)))))) + +(deftest test-session-fs-capabilities-forwarded-on-wire + (testing ":capabilities is forwarded on sessionFs.setProvider when configured (upstream PR #1299)" + ;; Build a fresh server + client so we can intercept the setProvider call sent during connect. + (let [server (mock/create-mock-server) + _ (mock/start-mock-server! server) + seen (atom {}) + _ (mock/set-request-hook! server (fn [method params] + (when (= "sessionFs.setProvider" method) + (swap! seen assoc method params)))) + client (sdk/client {:auto-start? false + :session-fs {:initial-cwd "/workspace" + :session-state-path "/state" + :conventions "posix" + :capabilities {:sqlite true}}}) + [in out] (mock/client-streams server)] + (try + (client/connect-with-streams! client in out) + (let [params (get @seen "sessionFs.setProvider")] + (is (= {:sqlite true} (:capabilities params))) + (is (= "/workspace" (:initialCwd params)))) + (finally + (try (sdk/stop! client) (catch Exception _)) + (Thread/sleep 50) + (mock/stop-mock-server! server))))) + + (testing ":capabilities is omitted when not configured" + (let [server (mock/create-mock-server) + _ (mock/start-mock-server! server) + seen (atom {}) + _ (mock/set-request-hook! server (fn [method params] + (when (= "sessionFs.setProvider" method) + (swap! seen assoc method params)))) + client (sdk/client {:auto-start? false + :session-fs {:initial-cwd "/workspace" + :session-state-path "/state" + :conventions "posix"}}) + [in out] (mock/client-streams server)] + (try + (client/connect-with-streams! client in out) + (let [params (get @seen "sessionFs.setProvider")] + (is (not (contains? params :capabilities)))) + (finally + (try (sdk/stop! client) (catch Exception _)) + (Thread/sleep 50) + (mock/stop-mock-server! server)))))) + +(deftest test-session-fs-sqlite-capability-validation + (testing "create-session throws when capabilities.sqlite is declared but provider lacks :sqlite" + (let [client-with-fs (assoc *test-client* :session-fs {:initial-cwd "/workspace" + :session-state-path "/state" + :conventions "posix" + :capabilities {:sqlite true}})] + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"capabilities\.sqlite" + (sdk/create-session client-with-fs + {:on-permission-request sdk/approve-all + :create-session-fs-handler + (fn [_session] + {:read-file (fn [_] "x") + :write-file (fn [_ _ _] nil) + :append-file (fn [_ _ _] nil) + :exists (fn [_] true) + :stat (fn [_] {:is-file true :is-directory false :size 1 :mtime "x" :birthtime "x"}) + :mkdir (fn [_ _ _] nil) + :readdir (fn [_] []) + :readdir-with-types (fn [_] []) + :rm (fn [_ _ _] nil) + :rename (fn [_ _] nil)})})))))) + ;; ----------------------------------------------------------------------------- ;; Hooks Tests (server→client RPC) ;; ----------------------------------------------------------------------------- diff --git a/test/github/copilot_sdk/mock_server.clj b/test/github/copilot_sdk/mock_server.clj index bb30839..9775b33 100644 --- a/test/github/copilot_sdk/mock_server.clj +++ b/test/github/copilot_sdk/mock_server.clj @@ -389,6 +389,7 @@ "session.usage.getMetrics" {} "session.remote.enable" {:url "https://copilot-remote.test/abc" :remoteSteerable true} "session.remote.disable" {} + "sessionFs.setProvider" {:ok true} (throw (ex-info "Method not found" {:code -32601 :method method}))) ;; Merge hook-provided data into result only when hook returns ::merge-response ;; This prevents accidental response mutation from spy hooks (e.g. swap! return values) From 1b1b879a3fc5c8a0db7e1f78da455c9743f203b4 Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Wed, 20 May 2026 18:29:14 +0200 Subject: [PATCH 2/2] Address Copilot Code Review feedback (PR #108) - session.clj: capabilities.sqlite validation now requires BOTH :sqlite-query and :sqlite-exists (previously only :sqlite-query). A low-level handler declaring only :sqlite-query would have passed validation but failed at runtime with an opaque "Unknown sessionFs method" error when sessionFs.sqliteExists arrived. The throw now reports which handler keys are missing. - doc/reference/API.md: clarify that the low-level handler map requires the 10 core FS operations and the two :sqlite-* keys are optional (only required when :capabilities {:sqlite true} is advertised). - integration_test.clj: regression test that create-session throws when capabilities.sqlite=true and the low-level handler has :sqlite-query but not :sqlite-exists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/reference/API.md | 9 ++++-- src/github/copilot_sdk/session.clj | 14 +++++---- test/github/copilot_sdk/integration_test.clj | 30 +++++++++++++++++++- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/doc/reference/API.md b/doc/reference/API.md index 157896d..08c327a 100644 --- a/doc/reference/API.md +++ b/doc/reference/API.md @@ -2165,7 +2165,10 @@ Use `create-session-fs-adapter` when you need the low-level handler map explicit (copilot/create-session-fs-adapter provider)) ``` -The low-level handler map requires all 10 operations: +The low-level handler map requires the 10 core FS operations below. The two +`:sqlite-*` keys are optional and only required when the client advertises +`:capabilities {:sqlite true}` on its `:session-fs` config (see +[SQLite support](#sqlite-support-optional)). | Key | Params | Returns | |-----|--------|---------| @@ -2179,8 +2182,8 @@ The low-level handler map requires all 10 operations: | `:readdir-with-types` | `{:session-id :path}` | `{:entries [...]}` | | `:rm` | `{:session-id :path :recursive :force}` | nil | | `:rename` | `{:session-id :src :dest}` | nil | -| `:sqlite-query` | `{:session-id :query-type :query :params}` | `{:rows [...] :columns [...] :rows-affected n}` (optional) | -| `:sqlite-exists` | `{:session-id}` | `{:exists true/false}` (optional) | +| `:sqlite-query` _(optional)_ | `{:session-id :query-type :query :params}` | `{:rows [...] :columns [...] :rows-affected n}` | +| `:sqlite-exists` _(optional)_ | `{:session-id}` | `{:exists true/false}` | Handler functions may return values directly or via core.async channels. diff --git a/src/github/copilot_sdk/session.clj b/src/github/copilot_sdk/session.clj index 87d2abd..44a1e7e 100644 --- a/src/github/copilot_sdk/session.clj +++ b/src/github/copilot_sdk/session.clj @@ -156,17 +156,21 @@ when :session-fs is enabled. Handler is a map of keyword→fn for FS operations. Upstream PR #1299: when the client's :session-fs config declares - `:capabilities {:sqlite true}` but the per-session handler does not expose - :sqlite-query, throw to prevent silent SQL dispatch failures at runtime." + `:capabilities {:sqlite true}` the per-session handler must expose BOTH + :sqlite-query and :sqlite-exists. Otherwise the runtime would route + sessionFs.sqliteExists (or sessionFs.sqliteQuery) to a missing handler key + and surface an opaque \"Unknown sessionFs method\" error at runtime." [client session-id handler] (let [validated (validate-session-fs-handler! handler {:session-id session-id}) sqlite-declared? (boolean (get-in client [:session-fs :capabilities :sqlite])) - handler-has-sqlite? (boolean (:sqlite-query validated))] - (when (and sqlite-declared? (not handler-has-sqlite?)) + missing (when sqlite-declared? + (remove #(contains? validated %) [:sqlite-query :sqlite-exists]))] + (when (seq missing) (throw (ex-info "SessionFs config declares capabilities.sqlite but the provider does not implement sqlite." {:session-id session-id - :capabilities (get-in client [:session-fs :capabilities])}))) + :capabilities (get-in client [:session-fs :capabilities]) + :missing-handlers (vec missing)}))) (swap! (:state client) assoc-in [:sessions session-id :session-fs-handler] validated))) (defn- channel? diff --git a/test/github/copilot_sdk/integration_test.clj b/test/github/copilot_sdk/integration_test.clj index a5bf6cf..940f54b 100644 --- a/test/github/copilot_sdk/integration_test.clj +++ b/test/github/copilot_sdk/integration_test.clj @@ -2868,7 +2868,35 @@ :readdir (fn [_] []) :readdir-with-types (fn [_] []) :rm (fn [_ _ _] nil) - :rename (fn [_ _] nil)})})))))) + :rename (fn [_ _] nil)})}))))) + + (testing "create-session throws when capabilities.sqlite is declared with only :sqlite-query (review feedback)" + ;; Low-level handler shape: presence of :sqlite-query alone must NOT pass + ;; validation, since sessionFs.sqliteExists would route to a missing key + ;; and surface as an opaque \"Unknown sessionFs method\" error at runtime. + (let [client-with-fs (assoc *test-client* :session-fs {:initial-cwd "/workspace" + :session-state-path "/state" + :conventions "posix" + :capabilities {:sqlite true}})] + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"capabilities\.sqlite" + (sdk/create-session client-with-fs + {:on-permission-request sdk/approve-all + :create-session-fs-handler + (fn [_session] + {:read-file (fn [_] "x") + :write-file (fn [_ _ _] nil) + :append-file (fn [_ _ _] nil) + :exists (fn [_] true) + :stat (fn [_] {:is-file true :is-directory false :size 1 :mtime "x" :birthtime "x"}) + :mkdir (fn [_ _ _] nil) + :readdir (fn [_] []) + :readdir-with-types (fn [_] []) + :rm (fn [_ _ _] nil) + :rename (fn [_ _] nil) + ;; only :sqlite-query, missing :sqlite-exists + :sqlite-query (fn [_] {:rows [] :columns [] :rows-affected 0})})})))))) ;; ----------------------------------------------------------------------------- ;; Hooks Tests (server→client RPC)