diff --git a/.changeset/fix-provider-auth-invalidation.md b/.changeset/fix-provider-auth-invalidation.md deleted file mode 100644 index 179d57d8a54..00000000000 --- a/.changeset/fix-provider-auth-invalidation.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"kilo-code": patch ---- - -Fixed default model falling back to the free model after login or org switch by invalidating cached provider state when auth changes. diff --git a/.changeset/gateway-models-feature-header.md b/.changeset/gateway-models-feature-header.md deleted file mode 100644 index 4021e29ea48..00000000000 --- a/.changeset/gateway-models-feature-header.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-gateway": patch ---- - -Include the feature header when requesting the models list from Kilo Gateway diff --git a/.changeset/git-stats-polling.md b/.changeset/git-stats-polling.md deleted file mode 100644 index a3e7e0d02dd..00000000000 --- a/.changeset/git-stats-polling.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Reduce git process load via visibility-aware polling and resolution caching in GitStatsPoller diff --git a/.changeset/opencode-1310.md b/.changeset/opencode-1310.md deleted file mode 100644 index 7a5700a9b0a..00000000000 --- a/.changeset/opencode-1310.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"kilo-code": patch -"@kilocode/cli": patch ---- - -Merged upstream opencode changes from v1.3.10: - -- Subagent tool calls stay clickable while pending -- Improved storage migration reliability -- Better muted text contrast in Catppuccin themes diff --git a/.changeset/opencode-136.md b/.changeset/opencode-136.md deleted file mode 100644 index 0a2b865d090..00000000000 --- a/.changeset/opencode-136.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"kilo-code": patch -"@kilocode/cli": patch ---- - -Merged upstream opencode changes from v1.3.6: - -- Fixed token usage double-counting for Anthropic and Amazon Bedrock providers -- Fixed variant dialog search filtering diff --git a/.changeset/opencode-137.md b/.changeset/opencode-137.md deleted file mode 100644 index 69ba99cfeb9..00000000000 --- a/.changeset/opencode-137.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"kilo-code": patch -"@kilocode/cli": patch ---- - -Merged upstream opencode changes from v1.3.7: - -- Added first-class PowerShell support on Windows -- Plugin installs now preserve JSONC comments in configuration files -- Improved variant modal behavior to be less intrusive diff --git a/.changeset/subsession-costs.md b/.changeset/subsession-costs.md deleted file mode 100644 index 3383a098108..00000000000 --- a/.changeset/subsession-costs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Make subsession costs in TaskHeader tooltip more readable with many subsessions diff --git a/.changeset/terminal-context-mention.md b/.changeset/terminal-context-mention.md deleted file mode 100644 index 3228399c01e..00000000000 --- a/.changeset/terminal-context-mention.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Add @terminal context mention support to the chat input. Type @terminal to include your active VS Code terminal output as context, with output safety limits (500 lines / 50K chars) and truncation. Works in both the sidebar chat and Agent Manager. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 156ba3c5431..7d4046df932 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -46,7 +46,7 @@ jobs: - uses: ./.github/actions/setup-bun - # kilocode_change start - install deps for changeset changelog generation + # kilocode_change start - install deps for version script workspace resolution - name: Install dependencies run: bun install diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c054d97674..29daebae67e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,9 +28,9 @@ jobs: matrix: settings: - name: linux - host: blacksmith-4vcpu-ubuntu-2404 + host: blacksmith-8vcpu-ubuntu-2404 - name: windows - host: blacksmith-4vcpu-windows-2025 + host: blacksmith-8vcpu-windows-2025 runs-on: ${{ matrix.settings.host }} defaults: run: diff --git a/bun.lock b/bun.lock index 28363ec8047..6100c91b6e7 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.2.10", + "version": "7.2.11", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -86,7 +86,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.2.10", + "version": "7.2.11", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -119,7 +119,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.2.10", + "version": "7.2.11", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -170,7 +170,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.10", + "version": "7.2.11", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -199,7 +199,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.10", + "version": "7.2.11", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -234,7 +234,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.10", + "version": "7.2.11", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -247,7 +247,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.10", + "version": "7.2.11", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", @@ -267,7 +267,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.10", + "version": "7.2.11", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/util": "workspace:*", @@ -302,7 +302,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.10", + "version": "7.2.11", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -355,7 +355,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.10", + "version": "7.2.11", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -501,7 +501,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.10", + "version": "7.2.11", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -526,7 +526,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.10", + "version": "7.2.11", "dependencies": { "semver": "^7.6.3", }, @@ -537,7 +537,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.10", + "version": "7.2.11", "dependencies": { "cross-spawn": "catalog:", }, @@ -559,7 +559,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.10", + "version": "7.2.11", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -582,7 +582,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.10", + "version": "7.2.11", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -632,7 +632,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "7.2.10", + "version": "7.2.11", "dependencies": { "zod": "catalog:", }, diff --git a/package.json b/package.json index 271ceaa7ef6..f2ce0611388 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,6 @@ "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch" }, - "version": "7.2.10", + "version": "7.2.11", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 4f02f6daefd..a6b403b2fb9 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.2.10", + "version": "7.2.11", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index a83140b1732..1ee122df81c 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.2.10", + "version": "7.2.11", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 11233d74b07..beb1e49cfe0 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.2.10", + "version": "7.2.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index e4e3e02d884..db7834bf404 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.2.10" +version = "7.2.11" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.10/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.10/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.10/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.10/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.10/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.11/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 0ef9333e42a..cc4601ab18e 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.10", + "version": "7.2.11", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md index 72f9994d1db..18629f8bdf1 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md @@ -7,8 +7,6 @@ description: "How to use MCP servers in Kilo Code" Model Context Protocol (MCP) extends Kilo Code's capabilities by connecting to external tools and services. This guide covers everything you need to know about using MCP with Kilo Code. -{% youtube url="https://youtu.be/6O9RQoQRX8A" caption="Demonstrating MCP installation in Kilo Code" /%} - ## Configuring MCP Servers {% tabs %} @@ -73,6 +71,16 @@ MCP servers are configured under the `mcp` key in `kilo.jsonc`: Remote servers support OAuth 2.0 authentication. If the server supports it, Kilo Code will automatically start the OAuth flow when you connect. You can also disable OAuth with `"oauth": false`. +{% /tab %} +{% tab label="CLI" %} + +The CLI accepts several config filenames. The recommended file is `kilo.json`: + +| Scope | Recommended Path | Also supported | +| ----------- | ------------------------------------ | -------------------------------------------------------------- | +| **Global** | `~/.config/kilo/kilo.json` | `kilo.jsonc`, `opencode.json`, `opencode.jsonc`, `config.json` | +| **Project** | `./kilo.json` or `./.kilo/kilo.json` | `kilo.jsonc`, `opencode.jsonc`, `opencode.json` | + {% /tab %} {% tab label="VSCode (Legacy)" %} @@ -85,45 +93,12 @@ MCP server configurations can be managed at two levels: **global** (applies acro Project-level configs can be committed to version control to share with your team. -{% /tab %} -{% tab label="CLI" %} - -The CLI accepts several config filenames. The recommended file is `kilo.json`: - -| Scope | Recommended Path | Also supported | -| ----------- | ------------------------------------ | -------------------------------------------------------------- | -| **Global** | `~/.config/kilo/kilo.json` | `kilo.jsonc`, `opencode.json`, `opencode.jsonc`, `config.json` | -| **Project** | `./kilo.json` or `./.kilo/kilo.json` | `kilo.jsonc`, `opencode.jsonc`, `opencode.json` | - {% /tab %} {% /tabs %} ## Configuration Format {% tabs %} -{% tab label="VSCode (Legacy)" %} - -Both global and project-level files use a JSON format with a `mcpServers` object containing named server configurations: - -```json -{ - "mcpServers": { - "server1": { - "command": "python", - "args": ["/path/to/server.py"], - "env": { - "API_KEY": "your_api_key" - }, - "alwaysAllow": ["tool1", "tool2"], - "disabled": false - } - } -} -``` - -_Example of MCP Server config in Kilo Code (STDIO Transport)_ - -{% /tab %} {% tab label="VSCode" %} In the VS Code extension, open **Settings → MCP** and click **Add Server** to configure a new server through the UI. You can also edit the config files directly — see the **CLI** tab for the JSON format. @@ -147,6 +122,29 @@ Add MCP servers under the `mcp` key in your config file. Each server has a uniqu You can disable a server by setting `enabled` to `false` without removing it from your config. +{% /tab %} +{% tab label="VSCode (Legacy)" %} + +Both global and project-level files use a JSON format with a `mcpServers` object containing named server configurations: + +```json +{ + "mcpServers": { + "server1": { + "command": "python", + "args": ["/path/to/server.py"], + "env": { + "API_KEY": "your_api_key" + }, + "alwaysAllow": ["tool1", "tool2"], + "disabled": false + } + } +} +``` + +_Example of MCP Server config in Kilo Code (STDIO Transport)_ + {% /tab %} {% /tabs %} @@ -174,25 +172,6 @@ For more in-depth information about how STDIO transport works, see [STDIO Transp STDIO configuration example: {% tabs %} -{% tab label="VSCode (Legacy)" %} - -```json -{ - "mcpServers": { - "local-server": { - "command": "node", - "args": ["/path/to/server.js"], - "env": { - "API_KEY": "your_api_key" - }, - "alwaysAllow": ["tool1", "tool2"], - "disabled": false - } - } -} -``` - -{% /tab %} {% tab label="VSCode" %} In the VS Code extension, open **Settings → MCP**, click **Add Server**, and choose **Local (stdio)**. Fill in the command, arguments, and optional environment variables through the UI. You can also edit the config files directly — see the **CLI** tab for the JSON format. @@ -225,6 +204,25 @@ In the VS Code extension, open **Settings → MCP**, click **Add Server**, and c | `enabled` | Boolean | No | Enable or disable the MCP server on startup. | | `timeout` | Number | No | Timeout in ms for fetching tools from the MCP server. Default: 30000. | +{% /tab %} +{% tab label="VSCode (Legacy)" %} + +```json +{ + "mcpServers": { + "local-server": { + "command": "node", + "args": ["/path/to/server.js"], + "env": { + "API_KEY": "your_api_key" + }, + "alwaysAllow": ["tool1", "tool2"], + "disabled": false + } + } +} +``` + {% /tab %} {% /tabs %} @@ -238,25 +236,6 @@ Used for remote servers accessed over HTTP/HTTPS: - Allows centralized deployment and management {% tabs %} -{% tab label="VSCode (Legacy)" %} - -```json -{ - "mcpServers": { - "remote-server": { - "type": "streamable-http", - "url": "https://your-server-url.com/mcp", - "headers": { - "Authorization": "Bearer your-token" - }, - "alwaysAllow": ["tool3"], - "disabled": false - } - } -} -``` - -{% /tab %} {% tab label="VSCode" %} In the VS Code extension, open **Settings → MCP**, click **Add Server**, and choose **Remote (HTTP)**. Enter the server URL and optional headers through the UI. You can also edit the config files directly — see the **CLI** tab for the JSON format. @@ -289,6 +268,25 @@ In the VS Code extension, open **Settings → MCP**, click **Add Server**, and c | `headers` | Object | No | HTTP headers to send with requests. | | `timeout` | Number | No | Timeout in ms for fetching tools from the MCP server. Default: 30000. | +{% /tab %} +{% tab label="VSCode (Legacy)" %} + +```json +{ + "mcpServers": { + "remote-server": { + "type": "streamable-http", + "url": "https://your-server-url.com/mcp", + "headers": { + "Authorization": "Bearer your-token" + }, + "alwaysAllow": ["tool3"], + "disabled": false + } + } +} +``` + {% /tab %} {% /tabs %} @@ -326,6 +324,68 @@ SSE configuration example: ## Managing MCP Servers {% tabs %} +{% tab label="VSCode" %} + +In the VS Code extension, manage MCP servers from **Settings → MCP**: + +- **Add a server**: Click **Add Server** and fill in the details +- **Enable/disable**: Toggle a server on or off without removing its configuration +- **Delete**: Remove a server from the list + +The extension also supports the `{env:VARIABLE_NAME}` syntax in config files to reference environment variables (see the **CLI** tab for details). + +{% /tab %} +{% tab label="CLI" %} + +### CLI Commands + +| Command | Description | +| ----------------- | ------------------------------- | +| `kilo mcp list` | List all configured MCP servers | +| `kilo mcp add` | Add an MCP server | +| `kilo mcp auth` | Authenticate with an MCP server | +| `kilo mcp logout` | Log out from an MCP server | +| `kilo mcp debug` | Debug an MCP server connection | + +### Enabling or Disabling a Server + +Inside the interactive TUI, use the `/mcps` slash command to toggle MCP servers on or off. + +You can also edit your config directly. Set `enabled` to `false` to disable a server without deleting it, or `true` to enable it again: + +```json +{ + "mcp": { + "my-server": { + "type": "local", + "command": ["npx", "-y", "my-mcp-command"], + "enabled": false + } + } +} +``` + +Run `kilo mcp list` to verify the server status. + +### Environment Variables + +Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variables: + +```json +{ + "mcp": { + "my-server": { + "type": "remote", + "url": "https://mcp.example.com/mcp", + "headers": { + "Authorization": "Bearer {env:MY_API_KEY}" + } + } + } +} +``` + +{% /tab %} {% tab label="VSCode (Legacy)" %} ### Editing MCP Settings Files @@ -356,50 +416,6 @@ You can edit both global and project-level MCP configuration files directly from 1. Press the {% codicon name="activate" /%} toggle switch next to the MCP server to enable/disable it -{% /tab %} -{% tab label="VSCode" %} - -In the VS Code extension, manage MCP servers from **Settings → MCP**: - -- **Add a server**: Click **Add Server** and fill in the details -- **Enable/disable**: Toggle a server on or off without removing its configuration -- **Delete**: Remove a server from the list - -The extension also supports the `{env:VARIABLE_NAME}` syntax in config files to reference environment variables (see the **CLI** tab for details). - -{% /tab %} -{% tab label="CLI" %} - -### CLI Commands - -| Command | Description | -| ----------------- | ------------------------------- | -| `kilo mcp list` | List all configured MCP servers | -| `kilo mcp add` | Add an MCP server | -| `kilo mcp auth` | Authenticate with an MCP server | -| `kilo mcp logout` | Log out from an MCP server | -| `kilo mcp debug` | Debug an MCP server connection | - -Inside the interactive TUI, use the `/mcps` slash command to toggle MCP servers on or off. - -### Environment Variables - -Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variables: - -```json -{ - "mcp": { - "my-server": { - "type": "remote", - "url": "https://mcp.example.com/mcp", - "headers": { - "Authorization": "Bearer {env:MY_API_KEY}" - } - } - } -} -``` - {% /tab %} {% /tabs %} @@ -410,6 +426,11 @@ Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variab Set the `timeout` field (in milliseconds) in the server's config entry. The default is 10 seconds for local servers and 15 seconds for remote servers. +{% /tab %} +{% tab label="CLI" %} + +Set the `timeout` field (in milliseconds) in the server's config entry. The default is 30000 (30 seconds). + {% /tab %} {% tab label="VSCode (Legacy)" %} @@ -442,6 +463,27 @@ MCP tool calls use the same permission system as built-in tools. Each MCP tool's } ``` +{% /tab %} +{% tab label="CLI" %} + +Add `permission` entries to your config to auto-approve specific tools. MCP tool keys use the server name, an underscore, then the tool name: + +```json +{ + "mcp": { + "my-server": { + "type": "local", + "command": ["npx", "-y", "my-mcp-server"], + "enabled": true + } + }, + "permission": { + "my-server_tool1": "allow", + "my-server_tool2": "allow" + } +} +``` + {% /tab %} {% tab label="VSCode (Legacy)" %} @@ -458,61 +500,41 @@ When enabled, Kilo Code will automatically approve this specific tool without pr {% /tab %} {% /tabs %} -## Platform-Specific MCP Configuration Examples +## Platform-Specific Local Server Commands + +Local MCP server instructions are often written as shell commands, such as `npx -y @modelcontextprotocol/server-puppeteer`. Use the right command format for your operating system. {% tabs %} -{% tab label="VSCode (Legacy)" %} - -### Windows Configuration Example - -When setting up MCP servers on Windows, you'll need to use the Windows Command Prompt (`cmd`) to execute commands. Here's an example of configuring a Puppeteer MCP server on Windows: - -```json -{ - "mcpServers": { - "puppeteer": { - "command": "cmd", - "args": ["/c", "npx", "-y", "@modelcontextprotocol/server-puppeteer"] - } - } -} -``` - -This Windows-specific configuration: - -- Uses the `cmd` command to access the Windows Command Prompt -- Uses `/c` to tell cmd to execute the command and then terminate -- Uses `npx` to run the package without installing it permanently -- The `-y` flag automatically answers "yes" to any prompts during installation -- Runs the `@modelcontextprotocol/server-puppeteer` package which provides browser automation capabilities - -{% callout type="note" %} -For macOS or Linux, you would use a different configuration: - -```json -{ - "mcpServers": { - "puppeteer": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-puppeteer"] - } - } -} -``` - -{% /callout %} - -{% /tab %} {% tab label="VSCode" %} -In the VS Code extension, use **Settings → MCP → Add Server** to add any of the examples below through the UI. You can also edit the config files directly — see the **CLI** tab for the JSON format. +In the VS Code extension, open **Settings → MCP**, click **Add Server**, and choose **Local (stdio)**. + +### Windows + +Use `cmd` as the command and pass the package command as arguments: + +| Field | Value | +| ------------- | ----------------------------------------------------------- | +| **Name** | `puppeteer` | +| **Command** | `cmd` | +| **Arguments** | `/c`, `npx`, `-y`, `@modelcontextprotocol/server-puppeteer` | + +### macOS and Linux + +Use the executable directly: + +| Field | Value | +| ------------- | ---------------------------------------------- | +| **Name** | `puppeteer` | +| **Command** | `npx` | +| **Arguments** | `-y`, `@modelcontextprotocol/server-puppeteer` | {% /tab %} {% tab label="CLI" %} ### Windows -When setting up local MCP servers on Windows, use the full `cmd` invocation in the `command` array: +Use the full `cmd` invocation in the `command` array: ```json { @@ -526,7 +548,61 @@ When setting up local MCP servers on Windows, use the full `cmd` invocation in t } ``` -The same approach can be used for other MCP servers on Windows, adjusting the package name as needed for different server types. +### macOS and Linux + +Use `npx` directly: + +```json +{ + "mcp": { + "puppeteer": { + "type": "local", + "command": ["npx", "-y", "@modelcontextprotocol/server-puppeteer"], + "enabled": true + } + } +} +``` + +{% /tab %} +{% tab label="VSCode (Legacy)" %} + +### Windows + +Use `cmd` as the command and put the rest of the invocation in `args`: + +```json +{ + "mcpServers": { + "puppeteer": { + "command": "cmd", + "args": ["/c", "npx", "-y", "@modelcontextprotocol/server-puppeteer"] + } + } +} +``` + +### macOS and Linux + +Use `npx` directly: + +```json +{ + "mcpServers": { + "puppeteer": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"] + } + } +} +``` + +{% /tab %} +{% /tabs %} + +## MCP Server Examples + +These examples use the current `mcp` config format. In VS Code, use **Settings → MCP → Add Server** and enter the same type, URL, or command values through the UI. ### Figma Desktop @@ -573,9 +649,6 @@ Add the test MCP server for development: } ``` -{% /tab %} -{% /tabs %} - ## Finding and Installing MCP Servers Kilo Code does not come with any pre-installed MCP servers. You'll need to find and install them separately. @@ -607,14 +680,6 @@ Example: "Analyze the performance of my API" might use an MCP tool that tests AP - **`failed` status:** Check the CLI output for error details. Ensure commands and paths are correct. - **Tool Not Available:** Confirm the server is properly implementing the tool and it's not disabled in settings. -{% /tab %} -{% tab label="VSCode (Legacy)" %} - -- **Server Not Responding:** Check if the server process is running and verify network connectivity -- **Permission Errors:** Ensure proper API keys and credentials are configured in your `mcp_settings.json` (for global settings) or `.kilocode/mcp.json` (for project settings). -- **Tool Not Available:** Confirm the server is properly implementing the tool and it's not disabled in settings -- **Slow Performance:** Try adjusting the network timeout value for the specific MCP server - {% /tab %} {% tab label="CLI" %} @@ -623,6 +688,14 @@ Example: "Analyze the performance of my API" might use an MCP tool that tests AP - **Tool Not Available:** Confirm the server is properly implementing the tool and it is not disabled (`"enabled": false`) in your config. - **Slow Performance:** Increase the `timeout` value for the specific MCP server in your config. +{% /tab %} +{% tab label="VSCode (Legacy)" %} + +- **Server Not Responding:** Check if the server process is running and verify network connectivity +- **Permission Errors:** Ensure proper API keys and credentials are configured in your `mcp_settings.json` (for global settings) or `.kilocode/mcp.json` (for project settings). +- **Tool Not Available:** Confirm the server is properly implementing the tool and it's not disabled in settings +- **Slow Performance:** Try adjusting the network timeout value for the specific MCP server + {% /tab %} {% /tabs %} diff --git a/packages/kilo-docs/pages/kiloclaw/chat-platforms/discord.md b/packages/kilo-docs/pages/kiloclaw/chat-platforms/discord.md index 219e6799c68..3793146e642 100644 --- a/packages/kilo-docs/pages/kiloclaw/chat-platforms/discord.md +++ b/packages/kilo-docs/pages/kiloclaw/chat-platforms/discord.md @@ -1,11 +1,15 @@ --- title: "Discord" -description: "Connect your KiloClaw agent to Discord" +description: "Use KiloClaw with Discord: setup, DM access control, and channel configuration" --- # Discord -Connect your KiloClaw agent to Discord by creating a bot in the Discord Developer Portal and linking it to your KiloClaw dashboard. +This page covers everything you need to use KiloClaw with Discord: connecting your bot, controlling who can DM it, and adding it to specific channels. + +## Connecting KiloClaw to Discord + +Create a bot in the Discord Developer Portal and link it to your KiloClaw dashboard. ## Prerequisites @@ -65,3 +69,78 @@ After saving your token, click **Redeploy** (the yellow button at the top of the 3. You should get a response back with a pairing code 4. Return to [app.kilo.ai/claw](https://app.kilo.ai/claw) and confirm the pairing code and approve 5. You should now be able to chat with the bot from Discord + +## Restricting KiloClaw to DMs Only (Just You) + +By default, KiloClaw will respond to any DMs. To lock it down to only DMs with you: + +### Step 1: Find your Discord user ID + +1. In Discord, go to **User Settings** → **Advanced** → enable **Developer Mode** +2. Right-click your own avatar or username → **Copy User ID** + +Your user ID is a large number (e.g. `987654321098765432`). + +### Step 2: Configure DM-only access + +Tell your KiloClaw agent (via DM): + +> "Set Discord DM policy to allowlist with my user ID `987654321098765432` and disable guild responses." + +Or configure it directly in the OpenClaw Control UI config: + +```json +{ + "channels": { + "discord": { + "dmPolicy": "allowlist", + "allowFrom": ["987654321098765432"], + "groupPolicy": "disabled" + } + } +} +``` + +## Adding KiloClaw to a Specific Discord Channel + +By default, your KiloClaw will not respond in channels, even if added. To have KiloClaw participate in a specific channel: + +### Step 1: Get your server and channel IDs + +With Developer Mode enabled (User Settings → Advanced → Developer Mode): + +- Right-click the **server icon** → **Copy Server ID** +- Right-click the **channel name** in the sidebar → **Copy Channel ID** + +### Step 2: Configure the channel + +Tell your KiloClaw agent: + +> "Add Discord server `YOUR_SERVER_ID` and channel `YOUR_CHANNEL_ID` to the allowlist. Only respond to user `YOUR_USER_ID`." + +Or configure it directly: + +```json +{ + "channels": { + "discord": { + "groupPolicy": "allowlist", + "guilds": { + "YOUR_SERVER_ID": { + "requireMention": true, + "users": ["YOUR_USER_ID"], + "channels": { + "YOUR_CHANNEL_ID": { "allow": true } + } + } + } + } + } +} +``` + +Set `requireMention: false` if you want the bot to respond to every message without needing an @mention. + +{% callout type="tip" %} +Non-listed channels in a guild that has a `channels` block configured are automatically denied. Add each channel you want explicitly. See the [OpenClaw Discord documentation](https://docs.openclaw.ai/channels/discord) for advanced access control options. +{% /callout %} diff --git a/packages/kilo-docs/pages/kiloclaw/chat-platforms/slack.md b/packages/kilo-docs/pages/kiloclaw/chat-platforms/slack.md index 2df508b5f38..790f56a6ac0 100644 --- a/packages/kilo-docs/pages/kiloclaw/chat-platforms/slack.md +++ b/packages/kilo-docs/pages/kiloclaw/chat-platforms/slack.md @@ -1,15 +1,19 @@ --- title: "Slack" -description: "Connect your KiloClaw agent to Slack" +description: "Using KiloClaw with Slack" --- # Slack +This page covers everything you need to use KiloClaw with Slack: connecting your bot, controlling who can DM it, and adding it to channels. + +## Connecting KiloClaw to Slack + {% youtube url="https://youtu.be/Q5bt-qH-_pY" title="Slack Setup Guide" caption="How to connect your KiloClaw agent to Slack" /%} -Connect your KiloClaw agent to Slack by creating a Slack app from the OpenClaw manifest and linking it to your KiloClaw dashboard. +Create a Slack app from the OpenClaw manifest and link it to your KiloClaw dashboard. -## Step 1: Create a Slack App from the OpenClaw Manifest +### Step 1: Create a Slack App from the OpenClaw Manifest 1. Go to [Slack App Management](https://api.slack.com/apps) and click **Create New App** → **From a Manifest** 2. Copy the manifest from the [OpenClaw docs](https://docs.openclaw.ai/channels/slack#manifest-and-scope-checklist) @@ -19,7 +23,7 @@ Connect your KiloClaw agent to Slack by creating a Slack app from the OpenClaw m - Update the slash command if desired (e.g., `/kiloclaw`) 5. Click **Create** -## Step 2: Generate Tokens +### Step 2: Generate Tokens You need two tokens from Slack: @@ -36,7 +40,7 @@ You need two tokens from Slack: 2. Install the app to your workspace 3. Copy the **Bot User OAuth Token** (starts with `xoxb-`) -## Step 3: Connect Slack to KiloClaw +### Step 3: Connect Slack to KiloClaw 1. In the [KiloClaw UI](https://app.kilo.ai/claw), find the Slack integration section (may show "not configured") 2. Enter both tokens: @@ -45,10 +49,94 @@ You need two tokens from Slack: 3. Click **Save** 4. Scroll to the top of the KiloClaw UI and click **Redeploy**. Wait for the instance to come back up -## Step 4: Pair Slack with KiloClaw +### Step 4: Pair Slack with KiloClaw 1. In Slack, DM the app and send any message — this triggers the pairing flow - 2. The app will return a pairing code -3. Return to [app.kilocode.ai/claw](https://app.kilocode.ai/claw) and confirm the pairing code and approve +3. Return to [app.kilo.ai/claw](https://app.kilo.ai/claw) and confirm the pairing code and approve 4. You should now be able to DM the bot from Slack. You will need to add the bot to any individual channels and tell it to update its config for any channels you want it to participate in. + +## Changing Response Behavior + +By default, KiloClaw can respond to any DMs and will not respond in Slack channels, even if added. + +## Making KiloClaw DM-Only (from you) + +By default, KiloClaw will respond to DMs from any user in Slack. + +### Step 1: Find your Slack user ID + +1. In Slack, click your name or profile picture +2. Click **Profile** +3. Click the **More** (⋯) menu → **Copy member ID** + +Your user ID starts with `U` (e.g. `U12345678`). + +### Step 2: Configure DM-only access + +Tell your KiloClaw agent: + +> "Set my Slack DM policy to allowlist with my user ID `U12345678` and disable group/channel responses." + +Or configure it directly in the OpenClaw Control UI config: + +```json +{ + "channels": { + "slack": { + "dmPolicy": "allowlist", + "allowFrom": ["U12345678"], + "groupPolicy": "disabled" + } + } +} +``` + +This allows only your user ID to DM the bot and blocks it from responding in any channels. + +## Adding KiloClaw to a Slack Channel + +By default, KiloClaw will not respond in Slack channels, even if added. To have KiloClaw participate in a Slack channel: + +### Step 1: Invite the bot to the channel + +1. Open the Slack channel where you want to add the bot +2. Type `/invite @YourBotName` (use whatever name you gave your app) +3. The bot should appear in the channel member list + +### Step 2: Get the channel ID + +Channel IDs are more reliable than names. To find a channel's ID: + +1. Open the channel in Slack +2. Click the channel name at the top to open channel details +3. Scroll to the bottom — the channel ID starts with `C` (e.g. `C01234567`) + +### Step 3: Configure the channel + +Tell your KiloClaw agent (via DM): + +> "Allow responses in Slack channel `C01234567`. Require an @mention to respond." + +Or configure it directly: + +```json +{ + "channels": { + "slack": { + "groupPolicy": "allowlist", + "channels": { + "C01234567": { + "requireMention": true + } + } + } + } +} +``` + +Set `requireMention: false` if you want the bot to respond to every message in the channel without needing an @mention. + +{% callout type="tip" %} +You can restrict which channel members can trigger the bot by adding a `users` allowlist inside the channel config entry. See the [OpenClaw Slack documentation](https://docs.openclaw.ai/channels/slack) for advanced access control options. +{% /callout %} diff --git a/packages/kilo-docs/pages/kiloclaw/chat-platforms/telegram.md b/packages/kilo-docs/pages/kiloclaw/chat-platforms/telegram.md index 71e0b204701..930ad381ba4 100644 --- a/packages/kilo-docs/pages/kiloclaw/chat-platforms/telegram.md +++ b/packages/kilo-docs/pages/kiloclaw/chat-platforms/telegram.md @@ -1,13 +1,17 @@ --- title: "Telegram" -description: "Connect your KiloClaw agent to Telegram" +description: "Use KiloClaw with Telegram: setup, DM access control, and group chat configuration" --- # Telegram +This page covers everything you need to use KiloClaw with Telegram: connecting your bot and adding it to group chats. + +## Connecting KiloClaw to Telegram + {% youtube url="https://youtu.be/hIfKz073hGw" title="Telegram Setup Guide" caption="How to connect your KiloClaw agent to Telegram" /%} -Connect your KiloClaw agent to Telegram by creating a bot via BotFather and linking it to your KiloClaw dashboard. +Create a bot via BotFather and link it to your KiloClaw dashboard. 1. Open Telegram and search for [@BotFather](https://t.me/BotFather) 2. Send `/newbot` and follow the prompts to create your bot @@ -22,5 +26,60 @@ Connect your KiloClaw agent to Telegram by creating a bot via BotFather and link You can remove or replace a configured token at any time. -> ℹ️ **Info** -> Advanced settings such as DM policy, allow lists, and groups can be configured in the OpenClaw Control UI after connecting. +## Adding KiloClaw to a Telegram Group Chat + +By default, KiloClaw will not participate in a group chat, even if added. If you would like to use your KiloClaw in a group chat, you must configure the KiloClaw settings. + +### Step 1: Add the bot to your group + +1. Open the Telegram group where you want to add your bot +2. Tap the group name at the top to open group info +3. Tap **Add Members** +4. Search for your bot's username and add it + +### Step 2: Set group visibility (Privacy Mode) + +By default, Telegram bots only see messages that directly mention them. To allow your bot to see all group messages: + +1. Open a chat with [@BotFather](https://t.me/BotFather) +2. Send `/setprivacy` and select your bot +3. Choose **Disable** +4. Remove the bot from the group and re-add it for the change to take effect + +### Step 3: Get the group chat ID + +You need the group's chat ID to configure access. Use one of these methods: + +- Forward a message from the group to [@userinfobot](https://t.me/userinfobot) — it will show the chat ID +- Or run `openclaw logs --follow` after sending a message in the group and read the `chat.id` value + +Group and supergroup IDs are negative numbers (e.g. `-1001234567890`). + +### Step 4: Configure the group in OpenClaw + +Tell your KiloClaw bot to add the group to its configuration. You can do this via DM: + +> "Add Telegram group `-1001234567890` to my allowed groups. Require a @mention to respond." + +Or configure it directly in the OpenClaw Control UI config: + +```json +{ + "channels": { + "telegram": { + "groupPolicy": "allowlist", + "groups": { + "-1001234567890": { + "requireMention": true + } + } + } + } +} +``` + +Set `requireMention: false` if you want the bot to respond to every message in the group without needing to be @mentioned. + +{% callout type="tip" %} +To restrict which group members can trigger the bot, add your user IDs to `allowFrom` inside the group config. See the [OpenClaw groups documentation](https://docs.openclaw.ai/channels/groups) for advanced access control patterns. +{% /callout %} diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 739475d3f23..1be0ce106f8 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.2.10", + "version": "7.2.11", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index f0132fa4b66..111ef03aeac 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.2.10", + "version": "7.2.11", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/.run/Run JetBrains Plugin.run.xml b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml similarity index 83% rename from .run/Run JetBrains Plugin.run.xml rename to packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml index ac959382073..09c4c15fc3b 100644 --- a/.run/Run JetBrains Plugin.run.xml +++ b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml @@ -1,5 +1,5 @@ - + - true true diff --git a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml new file mode 100644 index 00000000000..477fcb519b8 --- /dev/null +++ b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml @@ -0,0 +1,27 @@ + + + + + + + true + true + false + false + false + false + false + + + \ No newline at end of file diff --git a/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml b/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml new file mode 100644 index 00000000000..3fd81ac0533 --- /dev/null +++ b/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 66a80d3c354..9cab6db5e0d 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -1,5 +1,7 @@ package ai.kilocode.backend.app +import ai.kilocode.backend.cli.CliServer +import ai.kilocode.backend.cli.KiloBackendCliManager import ai.kilocode.backend.util.IntellijLog import ai.kilocode.backend.util.KiloLog import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager @@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import okhttp3.OkHttpClient import kotlinx.coroutines.sync.withLock import java.net.ConnectException import java.net.SocketTimeoutException @@ -89,8 +92,11 @@ class KiloBackendAppService private constructor( val events: SharedFlow get() = connection.events val api: DefaultApi? get() = connection.api + val http: OkHttpClient? get() = connection.apiClient + val port: Int get() = connection.port val sessions = KiloBackendSessionManager(cs, log) + val chat = KiloBackendChatManager(cs, log) val workspaces = KiloBackendWorkspaceManager(cs, sessions, log) @Volatile var profile: KiloProfile200Response? = null @@ -230,7 +236,8 @@ class KiloBackendAppService private constructor( profile = prof config = cfg notifications = notifs - sessions.start(connection.api!!, connection.events) + sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) + chat.start(connection.apiClient!!, connection.port, connection.events) workspaces.start(connection.api!!, connection.events) _appState.value = KiloAppState.Ready( AppData( @@ -256,8 +263,12 @@ class KiloBackendAppService private constructor( /** * Fetch the user profile. Returns [FetchResult.ok] with the response - * on success, [FetchResult.ok] with `null` when not logged in (401), - * or [FetchResult.fail] on other errors. Never throws. + * on success, [FetchResult.ok] with `null` when not logged in or when + * the server cannot reach the profile endpoint. Never throws. + * + * Profile is optional — 401 (not logged in) and 5xx (gateway/network + * errors) are both non-fatal. Only unexpected client errors are treated + * as failures. */ private suspend fun fetchProfile(): FetchResult { val client = connection.api @@ -274,6 +285,12 @@ class KiloBackendAppService private constructor( log.warn("Profile fetch failed: HTTP ${e.statusCode}", e) logResponseBody("profile", e) FetchResult.fail("profile", e) + } catch (e: ServerException) { + // 5xx from the CLI — profile endpoint is unreachable (no auth, + // gateway down, etc.). Treat the same as not-logged-in. + log.warn("Profile fetch: server error (${e.statusCode}) — treating as unavailable", e) + logResponseBody("profile", e) + FetchResult.ok(null) } catch (e: Exception) { log.warn("Profile fetch failed: ${e.message}", e) logResponseBody("profile", e) @@ -399,6 +416,7 @@ class KiloBackendAppService private constructor( eventWatcher?.cancel() } workspaces.stop() + chat.stop() sessions.stop() profile = null config = null diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt new file mode 100644 index 00000000000..379661bb358 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -0,0 +1,187 @@ +package ai.kilocode.backend.app + +import ai.kilocode.backend.cli.KiloCliDataParser +import ai.kilocode.backend.util.KiloLog +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PromptDto +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * Chat orchestrator that handles message sending, history loading, + * and SSE event routing for the agent chat UI. + * + * **Not an IntelliJ service** — owned by [KiloBackendAppService] which + * calls [start] after [KiloAppState.Ready] and [stop] on disconnect. + * + * All JSON parsing is delegated to [KiloCliDataParser]. + */ +class KiloBackendChatManager( + private val cs: CoroutineScope, + private val log: KiloLog, +) { + companion object { + private val JSON_TYPE = "application/json".toMediaType() + + private val CHAT_EVENTS = setOf( + "message.updated", + "message.removed", + "message.part.updated", + "message.part.delta", + "session.turn.open", + "session.turn.close", + "session.error", + ) + } + + private val _events = MutableSharedFlow(extraBufferCapacity = 128) + val events: SharedFlow = _events.asSharedFlow() + + private var client: OkHttpClient? = null + private var base: String? = null + private var watcher: Job? = null + + fun start(http: OkHttpClient, port: Int, sse: SharedFlow) { + client = http + base = "http://127.0.0.1:$port" + if (watcher?.isActive == true) return + watcher = cs.launch { + sse.collect { event -> + if (event.type in CHAT_EVENTS) { + log.debug("SSE chat event: type=${event.type}, data=${event.data.take(2000)}") + val parsed = KiloCliDataParser.parseChatEvent(event.type, event.data) + if (parsed != null) { + _events.emit(parsed) + } else { + log.warn("SSE parse returned null for type=${event.type}") + } + } + } + } + log.info("Chat manager started") + } + + fun stop() { + watcher?.cancel() + watcher = null + client = null + base = null + log.info("Chat manager stopped") + } + + // ------ prompt ------ + + fun prompt(id: String, dir: String, prompt: PromptDto) { + log.info("prompt: session=$id, dir=$dir, parts=${prompt.parts.size}, agent=${prompt.agent}, model=${prompt.providerID}/${prompt.modelID}") + val http = requireClient() + val url = requireBase() + + val body = KiloCliDataParser.buildPromptJson(prompt) + val target = "$url/session/$id/prompt_async?directory=${encode(dir)}" + log.debug("prompt: POST $target, body=$body") + val request = Request.Builder() + .url(target) + .post(body.toRequestBody(JSON_TYPE)) + .build() + + try { + http.newCall(request).execute().use { response -> + val code = response.code + if (!response.isSuccessful) { + val raw = response.body?.string() + log.warn("prompt_async failed: HTTP $code") + log.debug("prompt_async error body: $raw") + throw RuntimeException("prompt_async failed: HTTP $code") + } + } + } catch (e: RuntimeException) { + throw e + } catch (e: Exception) { + log.warn("prompt: HTTP call threw exception", e) + throw RuntimeException("prompt_async HTTP call failed: ${e.message}", e) + } + } + + // ------ abort ------ + + fun abort(id: String, dir: String) { + val http = requireClient() + val url = requireBase() + + val request = Request.Builder() + .url("$url/session/$id/abort?directory=${encode(dir)}") + .post("".toRequestBody(JSON_TYPE)) + .build() + + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + log.warn("abort failed: HTTP ${response.code}") + } + } + } + + // ------ messages ------ + + fun messages(id: String, dir: String): List { + val http = requireClient() + val url = requireBase() + + val request = Request.Builder() + .url("$url/session/$id/message?directory=${encode(dir)}") + .get() + .build() + + return http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + log.warn("messages failed: HTTP ${response.code}") + return emptyList() + } + val raw = response.body?.string() ?: return emptyList() + KiloCliDataParser.parseMessages(raw) + } + } + + // ------ config update ------ + + fun updateConfig(dir: String, update: ConfigUpdateDto) { + val http = requireClient() + val url = requireBase() + + val partial = KiloCliDataParser.buildConfigPartial(update) + + val request = Request.Builder() + .url("$url/global/config") + .patch(partial.toRequestBody(JSON_TYPE)) + .build() + + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val msg = response.body?.string() ?: "unknown error" + log.warn("config update failed: HTTP ${response.code} — $msg") + } else { + log.info("Config updated: model=${update.model}, agent=${update.agent}, temp=${update.temperature}") + } + } + } + + // ------ utilities ------ + + private fun requireClient(): OkHttpClient = + client ?: throw IllegalStateException("Chat manager not started") + + private fun requireBase(): String = + base ?: throw IllegalStateException("Chat manager not started") + + private fun encode(value: String): String = + java.net.URLEncoder.encode(value, "UTF-8") +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt index a2c4530317a..aec18db4144 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt @@ -1,7 +1,9 @@ package ai.kilocode.backend.app +import ai.kilocode.backend.cli.KiloBackendHttpClients +import ai.kilocode.backend.cli.KiloCliDataParser +import ai.kilocode.backend.cli.CliServer import ai.kilocode.backend.util.IntellijLog -import ai.kilocode.backend.util.KiloBackendHttpClients import ai.kilocode.backend.util.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import kotlinx.coroutines.CoroutineScope @@ -64,7 +66,6 @@ class KiloConnectionService( private const val HEARTBEAT_TIMEOUT_MS = 15_000L private const val HEALTH_POLL_INTERVAL_MS = 10_000L private const val RECONNECT_DELAY_MS = 250L - private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""") } private val _state = MutableStateFlow(ConnectionState.Disconnected) @@ -77,9 +78,13 @@ class KiloConnectionService( var api: DefaultApi? = null private set - private var apiClient: OkHttpClient? = null + /** OkHttp client used for API calls — no call/read timeout. Null when disconnected. */ + var apiClient: OkHttpClient? = null + private set private var healthClient: OkHttpClient? = null - private var port = 0 + /** Port the CLI server is listening on. Zero when disconnected. */ + var port = 0 + private set private var password = "" private val source = AtomicReference(null) @@ -214,7 +219,7 @@ class KiloConnectionService( override fun onEvent(src: EventSource, id: String?, type: String?, data: String) { lastEvent.set(System.currentTimeMillis()) - val kind = type ?: extractType(data) + val kind = type ?: KiloCliDataParser.extractEventType(data) cs.launch { _events.emit(SseEvent(type = kind, data = data)) } } @@ -327,9 +332,6 @@ class KiloConnectionService( _state.value = next } - internal fun extractType(data: String): String = - TYPE_REGEX.find(data)?.groupValues?.get(1) ?: "unknown" - fun dispose() { disposed = true source.getAndSet(null)?.cancel() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index bf9ff224422..95d61bd2118 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -1,5 +1,6 @@ package ai.kilocode.backend.app +import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.backend.util.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.model.SessionStatus @@ -16,6 +17,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import java.util.concurrent.ConcurrentHashMap /** @@ -29,30 +34,13 @@ import java.util.concurrent.ConcurrentHashMap * * SSE `session.status` events are consumed directly from the events * flow passed to [start], keeping the live [statuses] map current. + * + * All raw JSON parsing is delegated to [KiloCliDataParser]. */ class KiloBackendSessionManager( private val cs: CoroutineScope, private val log: KiloLog, ) { - companion object { - private val FIELD_RE = ConcurrentHashMap() - - /** Extract a top-level string field from JSON without a full parser. */ - internal fun extractField(json: String, field: String): String? { - val re = FIELD_RE.getOrPut(field) { - Regex(""""$field"\s*:\s*"([^"]+)"""") - } - return re.find(json)?.groupValues?.get(1) - } - - /** Extract a string field nested one level deep. */ - internal fun extractNested(json: String, outer: String, inner: String): String? { - val block = Regex(""""$outer"\s*:\s*\{([^}]+)}""") - .find(json)?.groupValues?.get(1) ?: return null - return extractField("{$block}", inner) - } - } - /** Per-session directory overrides (sessionId → worktree path). */ private val directories = ConcurrentHashMap() @@ -60,32 +48,34 @@ class KiloBackendSessionManager( val statuses: StateFlow> = _statuses.asStateFlow() private var client: DefaultApi? = null + private var http: OkHttpClient? = null + private var base: String? = null private var watcher: Job? = null - /** - * Activate the session manager with a connected API client and SSE stream. - * Called by [KiloBackendAppService] after [KiloAppState.Ready]. - */ - fun start(api: DefaultApi, events: SharedFlow) { + fun start(api: DefaultApi, httpClient: OkHttpClient, port: Int, events: SharedFlow) { client = api + http = httpClient + base = "http://127.0.0.1:$port" if (watcher?.isActive == true) return watcher = cs.launch { events.collect { event -> if (event.type == "session.status") { - handleStatus(event.data) + val pair = KiloCliDataParser.parseSessionStatus(event.data) + if (pair != null) { + _statuses.update { it + pair } + } } } } log.info("Session manager started") } - /** - * Deactivate the session manager. Called by [KiloBackendAppService] on disconnect. - */ fun stop() { watcher?.cancel() watcher = null client = null + http = null + base = null _statuses.value = emptyMap() log.info("Session manager stopped") } @@ -95,7 +85,6 @@ class KiloBackendSessionManager( // ------ session CRUD ------ - /** List root sessions for a directory and include current statuses. */ fun list(dir: String): SessionListDto { seed(dir) val raw = requireClient().sessionList(directory = dir, roots = true) @@ -105,16 +94,34 @@ class KiloBackendSessionManager( return SessionListDto(mapped, relevant) } - /** Create a new session in the given directory. */ - fun create(dir: String): SessionDto = - dto(requireClient().sessionCreate(directory = dir)) - /** - * Get a single session by ID. + * Create a new session in the given directory. * - * Uses the session list endpoint and filters by ID since - * [DefaultApi] does not expose the single-session GET. + * Uses raw HTTP because the generated client sends malformed JSON + * for the optional request body (Content-Type set but empty body). */ + fun create(dir: String): SessionDto { + val h = http ?: throw IllegalStateException("Session manager not started") + val url = base ?: throw IllegalStateException("Session manager not started") + val encoded = java.net.URLEncoder.encode(dir, "UTF-8") + log.info("Creating session: POST $url/session?directory=$encoded") + + val request = Request.Builder() + .url("$url/session?directory=$encoded") + .post("{}".toRequestBody("application/json".toMediaType())) + .build() + + h.newCall(request).execute().use { response -> + val raw = response.body?.string() + if (!response.isSuccessful) { + log.warn("Session create failed: HTTP ${response.code}, body=$raw") + throw RuntimeException("Session create failed: HTTP ${response.code} — $raw") + } + log.info("Session created: HTTP ${response.code}") + return KiloCliDataParser.parseSession(raw!!) + } + } + fun get(id: String, dir: String): SessionDto { val all = requireClient().sessionList(directory = dir) val raw = all.firstOrNull { it.id == id } @@ -122,13 +129,11 @@ class KiloBackendSessionManager( return dto(raw) } - /** Delete a session. */ fun delete(id: String, dir: String) { requireClient().sessionDelete(sessionID = id, directory = dir) directories.remove(id) } - /** Seed status map from the server for a specific directory. */ fun seed(dir: String) { try { val raw = requireClient().sessionStatus(directory = dir) @@ -149,16 +154,7 @@ class KiloBackendSessionManager( fun getDirectory(id: String, fallback: String): String = directories[id] ?: fallback - // ------ SSE event handling ------ - - private fun handleStatus(data: String) { - val id = extractField(data, "sessionID") ?: return - val type = extractNested(data, "status", "type") ?: "idle" - val msg = extractNested(data, "status", "message") - _statuses.update { it + (id to SessionStatusDto(type, msg)) } - } - - // ------ mapping ------ + // ------ mapping (generated API model → DTO) ------ private fun dto(s: ai.kilocode.jetbrains.api.model.Session) = SessionDto( id = s.id, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/CliServer.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt similarity index 94% rename from packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/CliServer.kt rename to packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt index 959f81e2593..f36f917acb9 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/CliServer.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/CliServer.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend.app +package ai.kilocode.backend.cli /** * Abstraction over the CLI process lifecycle. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt similarity index 85% rename from packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt rename to packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index c80ab7612b4..b3e9d844129 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend.app +package ai.kilocode.backend.cli import ai.kilocode.backend.util.IntellijLog import ai.kilocode.backend.util.KiloLog @@ -43,21 +43,11 @@ class KiloBackendCliManager( private var process: Process? = null private var hook: Thread? = null - /** - * When true, the next [extractCli] call deletes and re-extracts the binary - * regardless of the size check. Reset to false after extraction. - */ @Volatile override var forceExtract = false override fun process(): Process? = process - /** - * Extract the CLI binary (if needed) and spawn `kilo serve`. - * - * Must be called under [KiloBackendAppService]'s mutex — no internal - * synchronization is performed. - */ override suspend fun init(): CliServer.State { return try { val path = extractCli() @@ -67,8 +57,6 @@ class KiloBackendCliManager( } } catch (e: Exception) { log.warn("CLI startup failed", e) - // If spawn started a process but timed out (or failed after start), - // kill the orphaned process so it doesn't leak. process?.let { proc -> log.info("Cleaning up orphaned CLI process (pid=${proc.pid()})") process = null @@ -82,19 +70,12 @@ class KiloBackendCliManager( } } - /** - * Mark the given process as exited and clear state. - * Called from the process monitor when the CLI process dies. - */ override fun exited(proc: Process) { if (process != proc) return process = null uninstall() } - /** - * Kill the running CLI process and reset state so the next [init] spawns fresh. - */ override fun stop() { val proc = process ?: return process = null @@ -209,38 +190,27 @@ class KiloBackendCliManager( val proc = process ?: return process = null uninstall() - kill(proc, "Disposing") } private fun install(proc: Process) { uninstall() - val next = Thread({ log.info("Shutdown hook — killing CLI process tree (pid ${proc.pid()})") kill(proc, "Shutdown hook", wait = false) }, "kilo-cli-shutdown") - - val ok = runCatching { - Runtime.getRuntime().addShutdownHook(next) - } - + val ok = runCatching { Runtime.getRuntime().addShutdownHook(next) } if (ok.isFailure) { log.warn("Failed to install CLI shutdown hook", ok.exceptionOrNull()) return } - hook = next } private fun uninstall() { val curr = hook ?: return hook = null - - val ok = runCatching { - Runtime.getRuntime().removeShutdownHook(curr) - } - + val ok = runCatching { Runtime.getRuntime().removeShutdownHook(curr) } if (ok.isFailure) { log.info("Skipping CLI shutdown hook removal: ${ok.exceptionOrNull()?.message}") } @@ -250,9 +220,7 @@ class KiloBackendCliManager( log.info("$source — killing CLI process tree (pid ${proc.pid()})") children(proc).forEach { it.destroy() } proc.destroy() - if (!wait) return - if (!proc.waitFor(KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { log.warn("CLI process did not exit after SIGTERM, sending SIGKILL") children(proc).forEach { it.destroyForcibly() } @@ -260,22 +228,15 @@ class KiloBackendCliManager( } } - private fun children(proc: Process): List { - return proc.toHandle().descendants().toList().asReversed() - } + private fun children(proc: Process): List = + proc.toHandle().descendants().toList().asReversed() private fun platform(): String { val os = when { SystemInfo.isMac -> "darwin" SystemInfo.isLinux -> "linux" SystemInfo.isWindows -> "windows" - else -> throw IllegalStateException( - "Unsupported OS: ${ - System.getProperty( - "os.name" - ) - }" - ) + else -> throw IllegalStateException("Unsupported OS: ${System.getProperty("os.name")}") } val arch = when (CpuArch.CURRENT) { CpuArch.ARM64 -> "arm64" @@ -285,11 +246,6 @@ class KiloBackendCliManager( return "$os-$arch" } - /** - * Collect IDE-specific env vars for telemetry and gateway attribution. - * Catches all exceptions since these are best-effort — missing values - * won't prevent the CLI from starting. - */ private fun ideEnv(): Map = buildMap { runCatching { val info = ApplicationInfo.getInstance() @@ -310,10 +266,6 @@ class KiloBackendCliManager( }.onFailure { log.info("Could not read machine ID: ${it.message}") } } - /** - * Persistent machine ID stored in the IntelliJ system directory. - * Generated once and reused across restarts. - */ private fun machineId(): String { val file = File(PathManager.getSystemPath(), "kilo/machine-id") if (file.exists()) return file.readText().trim() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloBackendHttpClients.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClients.kt similarity index 98% rename from packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloBackendHttpClients.kt rename to packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClients.kt index bfb3397aaaf..3e71cf2bf33 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloBackendHttpClients.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClients.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend.util +package ai.kilocode.backend.cli import okhttp3.ConnectionPool import okhttp3.Interceptor diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt new file mode 100644 index 00000000000..5a1860e5dea --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -0,0 +1,343 @@ +package ai.kilocode.backend.cli + +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionStatusDto +import ai.kilocode.rpc.dto.SessionSummaryDto +import ai.kilocode.rpc.dto.SessionTimeDto +import ai.kilocode.rpc.dto.TokensDto +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import java.util.concurrent.ConcurrentHashMap + +/** + * Stateless parser that centralizes all CLI server response parsing. + * + * Callers pass raw JSON strings (SSE event data, HTTP response bodies) + * and get typed DTOs back. All JSON navigation, regex extraction, and + * manual serialization is contained here — no caller needs to know + * about [JsonObject] or kotlinx.serialization. + * + * Designed for testability: every public method is pure (no side effects, + * no dependencies). When a new parsing bug is found, add a test case + * with the raw JSON that caused the issue. + */ +object KiloCliDataParser { + + private val json = Json { ignoreUnknownKeys = true } + private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""") + private val FIELD_RE = ConcurrentHashMap() + + // ================================================================ + // SSE event parsing + // ================================================================ + + /** + * Extract the event type from raw SSE JSON data. + * Used as a fallback when OkHttp's EventSourceListener receives a null type. + */ + fun extractEventType(data: String): String = + TYPE_REGEX.find(data)?.groupValues?.get(1) ?: "unknown" + + /** + * Parse an SSE chat event into a [ChatEventDto]. + * Returns null if the event type is unrecognized or the JSON is malformed. + * + * Handles the GlobalEvent wrapper: `{ directory, payload: { type, properties } }` + * as well as flat events with top-level `properties`. + */ + fun parseChatEvent(type: String, data: String): ChatEventDto? { + val obj = tryParseObject(data) ?: return null + + // SSE data is a GlobalEvent: { directory, payload: { type, properties } } + val payload = obj["payload"]?.jsonObject ?: obj + val props = payload["properties"]?.jsonObject ?: return null + + return when (type) { + "message.updated" -> { + val sid = props.str("sessionID") ?: return null + val info = props["info"]?.jsonObject ?: return null + ChatEventDto.MessageUpdated(sid, parseMessage(info)) + } + + "message.removed" -> { + val sid = props.str("sessionID") ?: return null + val mid = props.str("messageID") ?: return null + ChatEventDto.MessageRemoved(sid, mid) + } + + "message.part.updated" -> { + val sid = props.str("sessionID") ?: return null + val part = props["part"]?.jsonObject ?: return null + ChatEventDto.PartUpdated(sid, parsePart(part)) + } + + "message.part.delta" -> { + val sid = props.str("sessionID") ?: return null + val mid = props.str("messageID") ?: return null + val pid = props.str("partID") ?: return null + val field = props.str("field") ?: return null + val delta = props.str("delta") ?: return null + ChatEventDto.PartDelta(sid, mid, pid, field, delta) + } + + "session.turn.open" -> { + val sid = props.str("sessionID") ?: return null + ChatEventDto.TurnOpen(sid) + } + + "session.turn.close" -> { + val sid = props.str("sessionID") ?: return null + val reason = props.str("reason") ?: "completed" + ChatEventDto.TurnClose(sid, reason) + } + + "session.error" -> { + val sid = props.str("sessionID") + val err = props["error"]?.jsonObject?.let { parseError(it) } + ChatEventDto.Error(sid, err) + } + + else -> null + } + } + + /** + * Parse an SSE `session.status` event into a (sessionID, [SessionStatusDto]) pair. + * Returns null if the required fields are missing. + * + * Uses regex extraction (no full JSON parse) for consistency with + * the existing high-throughput status event handling. + */ + fun parseSessionStatus(data: String): Pair? { + val id = extractField(data, "sessionID") ?: return null + val type = extractNested(data, "status", "type") ?: "idle" + val msg = extractNested(data, "status", "message") + return id to SessionStatusDto(type, msg) + } + + // ================================================================ + // HTTP response parsing + // ================================================================ + + /** + * Parse a session creation response (`POST /session`) into [SessionDto]. + */ + fun parseSession(raw: String): SessionDto { + val obj = json.parseToJsonElement(raw).jsonObject + return parseSessionObject(obj) + } + + /** + * Parse message history response (`GET /session/{id}/message`) + * into a list of messages with their parts. + */ + fun parseMessages(raw: String): List { + val arr = tryParseArray(raw) ?: return emptyList() + return arr.mapNotNull { elem -> + val obj = elem.jsonObject + val info = obj["info"]?.jsonObject ?: return@mapNotNull null + val parts = obj["parts"]?.jsonArray ?: JsonArray(emptyList()) + MessageWithPartsDto( + info = parseMessage(info), + parts = parts.map { parsePart(it.jsonObject) }, + ) + } + } + + // ================================================================ + // JSON serialization (DTO → JSON for outgoing requests) + // ================================================================ + + /** + * Build the JSON body for `POST /session/{id}/prompt_async`. + */ + fun buildPromptJson(prompt: PromptDto): String { + val parts = prompt.parts.joinToString(",") { part -> + """{"type":"${part.type}","text":${escape(part.text)}}""" + } + val sb = StringBuilder() + sb.append("""{"parts":[$parts]""") + val pid = prompt.providerID + val mid = prompt.modelID + if (pid != null && mid != null) { + sb.append(""","model":{"providerID":${escape(pid)},"modelID":${escape(mid)}}""") + } + val ag = prompt.agent + if (ag != null) { + sb.append(""","agent":${escape(ag)}""") + } + sb.append("}") + return sb.toString() + } + + /** + * Build the partial JSON body for `PATCH /global/config`. + */ + fun buildConfigPartial(update: ConfigUpdateDto): String { + val sb = StringBuilder("{") + var first = true + fun sep() { if (!first) sb.append(","); first = false } + + val model = update.model + if (model != null) { + sep(); sb.append(""""model":${escape(model)}""") + } + val agent = update.agent + if (agent != null) { + sep(); sb.append(""""default_agent":${escape(agent)}""") + } + val temp = update.temperature + if (temp != null) { + val target = agent ?: "ask" + sep(); sb.append(""""agent":{"$target":{"temperature":$temp}}""") + } + sb.append("}") + return sb.toString() + } + + // ================================================================ + // Internal — message/part/session parsing + // ================================================================ + + internal fun parseMessage(obj: JsonObject): MessageDto { + val time = obj["time"]?.jsonObject + val tokens = obj["tokens"]?.jsonObject + val error = obj["error"]?.jsonObject + + return MessageDto( + id = obj.str("id") ?: "", + sessionID = obj.str("sessionID") ?: "", + role = obj.str("role") ?: "unknown", + time = MessageTimeDto( + created = time?.num("created") ?: 0.0, + completed = time?.num("completed"), + ), + agent = obj.str("agent"), + providerID = obj.str("providerID"), + modelID = obj.str("modelID"), + parentID = obj.str("parentID"), + cost = obj.num("cost"), + tokens = tokens?.let { + val cache = it["cache"]?.jsonObject + TokensDto( + input = it.long("input") ?: 0, + output = it.long("output") ?: 0, + reasoning = it.long("reasoning") ?: 0, + cacheRead = cache?.long("read") ?: 0, + cacheWrite = cache?.long("write") ?: 0, + ) + }, + error = error?.let { parseError(it) }, + ) + } + + internal fun parsePart(obj: JsonObject): PartDto { + val state = obj["state"]?.jsonObject + return PartDto( + id = obj.str("id") ?: "", + sessionID = obj.str("sessionID") ?: "", + messageID = obj.str("messageID") ?: "", + type = obj.str("type") ?: "unknown", + text = obj.str("text"), + tool = obj.str("tool"), + state = state?.str("status"), + title = state?.str("title"), + ) + } + + internal fun parseError(obj: JsonObject): MessageErrorDto { + val type = obj.str("type") ?: obj.str("name") ?: "unknown" + val msg = obj.str("message") + ?: obj["data"]?.jsonObject?.str("message") + ?: obj.str("error") + return MessageErrorDto(type, msg) + } + + private fun parseSessionObject(obj: JsonObject): SessionDto { + val time = obj["time"]?.jsonObject + val summary = obj["summary"]?.jsonObject + return SessionDto( + id = obj.str("id") ?: "", + projectID = obj.str("projectID") ?: "", + directory = obj.str("directory") ?: "", + parentID = obj.str("parentID"), + title = obj.str("title") ?: "", + version = obj.str("version") ?: "", + time = SessionTimeDto( + created = time?.num("created") ?: 0.0, + updated = time?.num("updated") ?: 0.0, + archived = time?.num("archived"), + ), + summary = summary?.let { + SessionSummaryDto( + additions = it.long("additions")?.toInt() ?: 0, + deletions = it.long("deletions")?.toInt() ?: 0, + files = it.long("files")?.toInt() ?: 0, + ) + }, + ) + } + + // ================================================================ + // Internal — regex-based field extraction (for status events) + // ================================================================ + + private fun extractField(raw: String, field: String): String? { + val re = FIELD_RE.getOrPut(field) { + Regex(""""$field"\s*:\s*"([^"]+)"""") + } + return re.find(raw)?.groupValues?.get(1) + } + + private fun extractNested(raw: String, outer: String, inner: String): String? { + val block = Regex(""""$outer"\s*:\s*\{([^}]+)}""") + .find(raw)?.groupValues?.get(1) ?: return null + return extractField("{$block}", inner) + } + + // ================================================================ + // Internal — JSON helpers + // ================================================================ + + private fun tryParseObject(raw: String): JsonObject? = + try { json.parseToJsonElement(raw).jsonObject } catch (_: Exception) { null } + + private fun tryParseArray(raw: String): kotlinx.serialization.json.JsonArray? = + try { json.parseToJsonElement(raw).jsonArray } catch (_: Exception) { null } + + /** Escape and double-quote a string for manual JSON building. */ + private fun escape(value: String): String { + val escaped = value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + return "\"$escaped\"" + } +} + +// JsonObject convenience extensions +private fun JsonObject.str(key: String): String? = + this[key]?.jsonPrimitive?.contentOrNull + +private fun JsonObject.num(key: String): Double? = + this[key]?.jsonPrimitive?.doubleOrNull + +private fun JsonObject.long(key: String): Long? = + this[key]?.jsonPrimitive?.longOrNull diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiProvider.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiProvider.kt index ae30ed6887c..9b6eddcdeba 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiProvider.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiProvider.kt @@ -2,14 +2,14 @@ package ai.kilocode.backend.rpc -import ai.kilocode.rpc.KiloProjectRpcApi +import ai.kilocode.rpc.KiloWorkspaceRpcApi import com.intellij.platform.rpc.backend.RemoteApiProvider import fleet.rpc.remoteApiDescriptor internal class KiloProjectRpcApiProvider : RemoteApiProvider { override fun RemoteApiProvider.Sink.remoteApis() { - remoteApi(remoteApiDescriptor()) { - KiloProjectRpcApiImpl() + remoteApi(remoteApiDescriptor()) { + KiloWorkspaceRpcApiImpl() } } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index e2dcf4025d5..c0c7c5c823d 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -3,14 +3,21 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.app.KiloBackendChatManager import ai.kilocode.backend.app.KiloBackendSessionManager import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager import ai.kilocode.rpc.KiloSessionRpcApi +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter /** * Backend implementation of [KiloSessionRpcApi]. @@ -18,9 +25,13 @@ import kotlinx.coroutines.flow.Flow * Session CRUD routes through the [KiloBackendWorkspaceManager] to * get the correct workspace for a directory. Status tracking and * worktree directory management go directly to the - * [KiloBackendSessionManager]. + * [KiloBackendSessionManager]. Chat operations delegate to + * [KiloBackendChatManager]. */ class KiloSessionRpcApiImpl : KiloSessionRpcApi { + companion object { + private val LOG = Logger.getInstance(KiloSessionRpcApiImpl::class.java) + } private val workspaces: KiloBackendWorkspaceManager get() = service().workspaces @@ -28,11 +39,16 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { private val sessions: KiloBackendSessionManager get() = service().sessions + private val chat: KiloBackendChatManager + get() = service().chat + override suspend fun list(directory: String): SessionListDto = workspaces.get(directory).sessions() - override suspend fun create(directory: String): SessionDto = - workspaces.get(directory).createSession() + override suspend fun create(directory: String): SessionDto { + LOG.info("create session: directory=$directory") + return workspaces.get(directory).createSession() + } override suspend fun get(id: String, directory: String): SessionDto { val dir = sessions.getDirectory(id, directory) @@ -52,4 +68,34 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { override suspend fun getDirectory(id: String, fallback: String): String = sessions.getDirectory(id, fallback) + + // ------ chat ------ + + override suspend fun prompt(id: String, directory: String, prompt: PromptDto) { + LOG.info("prompt RPC: session=$id, dir=$directory, parts=${prompt.parts.size}") + chat.prompt(id, directory, prompt) + } + + override suspend fun abort(id: String, directory: String) = + chat.abort(id, directory) + + override suspend fun messages(id: String, directory: String): List = + chat.messages(id, directory) + + override suspend fun events(id: String, directory: String): Flow = + chat.events.filter { event -> + val sid = when (event) { + is ChatEventDto.MessageUpdated -> event.sessionID + is ChatEventDto.PartUpdated -> event.sessionID + is ChatEventDto.PartDelta -> event.sessionID + is ChatEventDto.TurnOpen -> event.sessionID + is ChatEventDto.TurnClose -> event.sessionID + is ChatEventDto.Error -> event.sessionID + is ChatEventDto.MessageRemoved -> event.sessionID + } + sid == id + } + + override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) = + chat.updateConfig(directory, config) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt similarity index 89% rename from packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt rename to packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 69335928b28..171a2e47d78 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloProjectRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -14,7 +14,7 @@ import ai.kilocode.backend.workspace.ModelInfo import ai.kilocode.backend.workspace.ProviderData import ai.kilocode.backend.workspace.ProviderInfo import ai.kilocode.backend.workspace.SkillInfo -import ai.kilocode.rpc.KiloProjectRpcApi +import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentsDto import ai.kilocode.rpc.dto.CommandDto @@ -26,6 +26,7 @@ import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.ProvidersDto import ai.kilocode.rpc.dto.SkillDto import com.intellij.openapi.components.service +import com.intellij.openapi.project.ProjectManager import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged @@ -34,19 +35,27 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map /** - * Backend implementation of [KiloProjectRpcApi]. + * Backend implementation of [KiloWorkspaceRpcApi]. * * Routes through the [KiloBackendWorkspaceManager] to get a workspace * for the given directory. No [ProjectManager] dependency — any * directory (including worktrees) can get a workspace. */ -class KiloProjectRpcApiImpl : KiloProjectRpcApi { +class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { private val app: KiloBackendAppService get() = service() private val manager: KiloBackendWorkspaceManager get() = app.workspaces + override suspend fun resolveProjectDirectory(hint: String): String { + // In monolith mode, find the open project whose basePath matches the hint. + // In split mode, the backend's project.basePath is the real directory. + val projects = ProjectManager.getInstance().openProjects + val match = projects.firstOrNull { !it.isDefault } + return match?.basePath ?: hint + } + /** * Emits workspace state for [directory]. Waits for the app to * reach [KiloAppState.Ready] before creating the workspace — diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloLog.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloLog.kt index db86201641a..ca870fdbfd1 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloLog.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloLog.kt @@ -3,6 +3,7 @@ package ai.kilocode.backend.util import com.intellij.openapi.diagnostic.Logger interface KiloLog { + fun debug(msg: String) fun info(msg: String) fun warn(msg: String, t: Throwable? = null) fun error(msg: String, t: Throwable? = null) @@ -10,6 +11,7 @@ interface KiloLog { internal class IntellijLog(cls: Class<*>) : KiloLog { private val delegate = Logger.getInstance(cls) + override fun debug(msg: String) = delegate.debug(msg) override fun info(msg: String) = delegate.info(msg) override fun warn(msg: String, t: Throwable?) { if (t != null) delegate.warn(msg, t) else delegate.warn(msg) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index 9f69228a851..b05e5140238 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -199,17 +199,17 @@ class KiloBackendWorkspace( ProviderInfo( id = p.id, name = p.name, - source = p.api, + source = p.source.value, models = p.models.mapValues { (_, m) -> ModelInfo( id = m.id, name = m.name, - attachment = m.attachment, - reasoning = m.reasoning, - temperature = m.temperature, - toolCall = m.toolCall, + attachment = m.capabilities.attachment, + reasoning = m.capabilities.reasoning, + temperature = m.capabilities.temperature, + toolCall = m.capabilities.toolcall, free = m.isFree ?: false, - status = m.status?.value, + status = m.status.value, ) }, ) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloAppStateTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloAppStateTest.kt similarity index 98% rename from packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloAppStateTest.kt rename to packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloAppStateTest.kt index fb1392adac5..94d5ed998d7 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloAppStateTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloAppStateTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend +package ai.kilocode.backend.app import ai.kilocode.backend.app.AppData import ai.kilocode.backend.app.KiloAppState diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt similarity index 99% rename from packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendAppServiceTest.kt rename to packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index 620420241cb..e9b6671b9f6 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend +package ai.kilocode.backend.app import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt similarity index 99% rename from packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt rename to packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt index 506a2b2e424..d98d978f5f3 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendSessionManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend +package ai.kilocode.backend.app import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService @@ -303,7 +303,7 @@ class KiloBackendSessionManagerTest { assertFailsWith { app.sessions.list("/test") } // Re-start (simulate what app service does on reconnect) - app.sessions.start(app.api!!, app.events) + app.sessions.start(app.api!!, app.http!!, app.port, app.events) // CRUD should work again val result = app.sessions.list("/test") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt similarity index 91% rename from packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt rename to packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt index 92a3aa7147b..71d75f474c5 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloConnectionServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt @@ -1,6 +1,6 @@ -package ai.kilocode.backend +package ai.kilocode.backend.app -import ai.kilocode.backend.app.CliServer +import ai.kilocode.backend.cli.CliServer import ai.kilocode.backend.app.ConnectionState import ai.kilocode.backend.app.KiloConnectionService import ai.kilocode.backend.testing.FakeCliServer @@ -147,18 +147,7 @@ class KiloConnectionServiceTest { assertTrue(fake.forceExtract) } - @Test - fun `extractType parses type from JSON data`() { - val svc = KiloConnectionService(scope, fake, {}, log) - val result = svc.extractType("""{"type":"global.config.updated","payload":{}}""") - assertEquals("global.config.updated", result) - } - - @Test - fun `extractType returns unknown for missing type`() { - val svc = KiloConnectionService(scope, fake, {}, log) - assertEquals("unknown", svc.extractType("""{"data":"something"}""")) - } + // extractType tests moved to KiloCliDataParserTest @Test fun `dispose transitions to Disconnected`() = runBlocking { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/ApiModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ApiModelSerializationTest.kt similarity index 99% rename from packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/ApiModelSerializationTest.kt rename to packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ApiModelSerializationTest.kt index 276985b5751..57fda81dec0 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/ApiModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ApiModelSerializationTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend +package ai.kilocode.backend.cli import ai.kilocode.jetbrains.api.infrastructure.Serializer import ai.kilocode.jetbrains.api.model.Config diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClientsTest.kt similarity index 97% rename from packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt rename to packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClientsTest.kt index a9ed85d2a64..53d126fdf18 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendHttpClientsTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendHttpClientsTest.kt @@ -1,6 +1,6 @@ -package ai.kilocode.backend +package ai.kilocode.backend.cli -import ai.kilocode.backend.util.KiloBackendHttpClients +import ai.kilocode.backend.cli.KiloBackendHttpClients import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import java.util.Base64 diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt new file mode 100644 index 00000000000..91f525ba1c5 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -0,0 +1,475 @@ +package ai.kilocode.backend.cli + +import ai.kilocode.backend.cli.KiloCliDataParser +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.PromptPartDto +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Pure unit tests for [KiloCliDataParser]. + * + * No mocks, no services, no coroutines — just JSON in → DTO out. + * When a new parsing bug is found, copy the raw JSON that caused + * the issue and add a test case here. + */ +class KiloCliDataParserTest { + + // ================================================================ + // extractEventType + // ================================================================ + + @Test + fun `extractEventType - parses type from JSON data`() { + val result = KiloCliDataParser.extractEventType( + """{"type":"global.config.updated","payload":{}}""" + ) + assertEquals("global.config.updated", result) + } + + @Test + fun `extractEventType - returns unknown for missing type`() { + assertEquals("unknown", KiloCliDataParser.extractEventType("""{"data":"something"}""")) + } + + @Test + fun `extractEventType - returns unknown for empty string`() { + assertEquals("unknown", KiloCliDataParser.extractEventType("")) + } + + // ================================================================ + // parseChatEvent — GlobalEvent wrapper + // ================================================================ + + @Test + fun `parseChatEvent - message updated with GlobalEvent wrapper`() { + val data = """{ + "directory": "/tmp/test", + "payload": { + "type": "message.updated", + "properties": { + "sessionID": "ses_123", + "info": { + "id": "msg_1", + "sessionID": "ses_123", + "role": "assistant", + "time": { "created": 1000.0 } + } + } + } + }""" + + val result = KiloCliDataParser.parseChatEvent("message.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.MessageUpdated) + assertEquals("ses_123", result.sessionID) + assertEquals("msg_1", result.info.id) + assertEquals("assistant", result.info.role) + } + + @Test + fun `parseChatEvent - flat event without payload wrapper`() { + val data = """{ + "type": "message.updated", + "properties": { + "sessionID": "ses_456", + "info": { + "id": "msg_2", + "sessionID": "ses_456", + "role": "user", + "time": { "created": 2000.0 } + } + } + }""" + + val result = KiloCliDataParser.parseChatEvent("message.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.MessageUpdated) + assertEquals("ses_456", result.sessionID) + assertEquals("user", result.info.role) + } + + // ================================================================ + // parseChatEvent — specific event types + // ================================================================ + + @Test + fun `parseChatEvent - message part delta`() { + val data = globalEvent(""" + "type": "message.part.delta", + "properties": { + "sessionID": "ses_1", + "messageID": "msg_1", + "partID": "part_1", + "field": "text", + "delta": "Hello world" + } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.delta", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartDelta) + assertEquals("ses_1", result.sessionID) + assertEquals("msg_1", result.messageID) + assertEquals("part_1", result.partID) + assertEquals("text", result.field) + assertEquals("Hello world", result.delta) + } + + @Test + fun `parseChatEvent - message part updated`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_1", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "text", + "text": "Hello" + } + } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartUpdated) + assertEquals("ses_1", result.sessionID) + assertEquals("part_1", result.part.id) + assertEquals("text", result.part.type) + assertEquals("Hello", result.part.text) + } + + @Test + fun `parseChatEvent - turn open`() { + val data = globalEvent(""" + "type": "session.turn.open", + "properties": { "sessionID": "ses_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("session.turn.open", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.TurnOpen) + assertEquals("ses_1", result.sessionID) + } + + @Test + fun `parseChatEvent - turn close`() { + val data = globalEvent(""" + "type": "session.turn.close", + "properties": { "sessionID": "ses_1", "reason": "completed" } + """) + + val result = KiloCliDataParser.parseChatEvent("session.turn.close", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.TurnClose) + assertEquals("ses_1", result.sessionID) + assertEquals("completed", result.reason) + } + + @Test + fun `parseChatEvent - session error`() { + val data = globalEvent(""" + "type": "session.error", + "properties": { + "sessionID": "ses_1", + "error": { "type": "provider_auth", "message": "Invalid key" } + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.error", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.Error) + assertEquals("ses_1", result.sessionID) + assertEquals("provider_auth", result.error?.type) + assertEquals("Invalid key", result.error?.message) + } + + @Test + fun `parseChatEvent - message removed`() { + val data = globalEvent(""" + "type": "message.removed", + "properties": { "sessionID": "ses_1", "messageID": "msg_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("message.removed", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.MessageRemoved) + assertEquals("ses_1", result.sessionID) + assertEquals("msg_1", result.messageID) + } + + // ================================================================ + // parseChatEvent — error cases + // ================================================================ + + @Test + fun `parseChatEvent - unknown type returns null`() { + val data = globalEvent(""" + "type": "some.unknown.event", + "properties": { "sessionID": "ses_1" } + """) + assertNull(KiloCliDataParser.parseChatEvent("some.unknown.event", data)) + } + + @Test + fun `parseChatEvent - malformed JSON returns null`() { + assertNull(KiloCliDataParser.parseChatEvent("message.updated", "not json")) + } + + @Test + fun `parseChatEvent - missing properties returns null`() { + assertNull(KiloCliDataParser.parseChatEvent("message.updated", """{"payload":{"type":"x"}}""")) + } + + @Test + fun `parseChatEvent - missing sessionID returns null`() { + val data = globalEvent(""" + "type": "message.updated", + "properties": { "info": { "id": "msg_1", "role": "user", "time": {} } } + """) + assertNull(KiloCliDataParser.parseChatEvent("message.updated", data)) + } + + // ================================================================ + // parseSessionStatus + // ================================================================ + + @Test + fun `parseSessionStatus - valid status event`() { + val data = """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}""" + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("ses_abc", result.first) + assertEquals("busy", result.second.type) + assertEquals("Running...", result.second.message) + } + + @Test + fun `parseSessionStatus - missing sessionID returns null`() { + val data = """{"status":{"type":"idle"}}""" + assertNull(KiloCliDataParser.parseSessionStatus(data)) + } + + @Test + fun `parseSessionStatus - missing status defaults to idle`() { + val data = """{"sessionID":"ses_xyz"}""" + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("idle", result.second.type) + assertNull(result.second.message) + } + + // ================================================================ + // parseSession + // ================================================================ + + @Test + fun `parseSession - full session response`() { + val raw = """{ + "id": "ses_abc", + "projectID": "proj_1", + "directory": "/tmp/project", + "parentID": null, + "title": "Test session", + "version": "1", + "time": { "created": 1000.0, "updated": 2000.0 }, + "summary": { "additions": 10, "deletions": 5, "files": 3 } + }""" + + val result = KiloCliDataParser.parseSession(raw) + assertEquals("ses_abc", result.id) + assertEquals("proj_1", result.projectID) + assertEquals("/tmp/project", result.directory) + assertNull(result.parentID) + assertEquals("Test session", result.title) + assertEquals(1000.0, result.time.created) + assertEquals(2000.0, result.time.updated) + assertNotNull(result.summary) + assertEquals(10, result.summary?.additions) + assertEquals(5, result.summary?.deletions) + assertEquals(3, result.summary?.files) + } + + @Test + fun `parseSession - minimal session response`() { + val raw = """{ + "id": "ses_min", + "projectID": "proj_2", + "directory": "/tmp", + "title": "", + "version": "0", + "time": { "created": 0.0, "updated": 0.0 } + }""" + + val result = KiloCliDataParser.parseSession(raw) + assertEquals("ses_min", result.id) + assertNull(result.summary) + } + + // ================================================================ + // parseMessages + // ================================================================ + + @Test + fun `parseMessages - empty array`() { + assertEquals(emptyList(), KiloCliDataParser.parseMessages("[]")) + } + + @Test + fun `parseMessages - user and assistant messages`() { + val raw = """[ + { + "info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } }, + "parts": [{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "Hello" }] + }, + { + "info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 } }, + "parts": [{ "id": "p2", "sessionID": "s1", "messageID": "m2", "type": "text", "text": "Hi there" }] + } + ]""" + + val result = KiloCliDataParser.parseMessages(raw) + assertEquals(2, result.size) + assertEquals("user", result[0].info.role) + assertEquals("Hello", result[0].parts[0].text) + assertEquals("assistant", result[1].info.role) + assertEquals("Hi there", result[1].parts[0].text) + } + + @Test + fun `parseMessages - message with tool parts`() { + val raw = """[{ + "info": { "id": "m1", "sessionID": "s1", "role": "assistant", "time": { "created": 1.0 } }, + "parts": [{ + "id": "p1", + "sessionID": "s1", + "messageID": "m1", + "type": "tool", + "tool": "read_file", + "state": { "status": "completed", "title": "Read file.txt" } + }] + }]""" + + val result = KiloCliDataParser.parseMessages(raw) + assertEquals(1, result.size) + val part = result[0].parts[0] + assertEquals("tool", part.type) + assertEquals("read_file", part.tool) + assertEquals("completed", part.state) + assertEquals("Read file.txt", part.title) + } + + @Test + fun `parseMessages - malformed JSON returns empty`() { + assertEquals(emptyList(), KiloCliDataParser.parseMessages("not json")) + } + + @Test + fun `parseMessages - message with tokens`() { + val raw = """[{ + "info": { + "id": "m1", "sessionID": "s1", "role": "assistant", + "time": { "created": 1.0, "completed": 2.0 }, + "tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 20, "write": 5 } }, + "cost": 0.005 + }, + "parts": [] + }]""" + + val result = KiloCliDataParser.parseMessages(raw) + val info = result[0].info + assertNotNull(info.tokens) + assertEquals(100L, info.tokens?.input) + assertEquals(50L, info.tokens?.output) + assertEquals(10L, info.tokens?.reasoning) + assertEquals(20L, info.tokens?.cacheRead) + assertEquals(5L, info.tokens?.cacheWrite) + assertEquals(0.005, info.cost) + assertEquals(2.0, info.time.completed) + } + + // ================================================================ + // buildPromptJson + // ================================================================ + + @Test + fun `buildPromptJson - text only`() { + val prompt = PromptDto(parts = listOf(PromptPartDto("text", "Hello"))) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertEquals("""{"parts":[{"type":"text","text":"Hello"}]}""", result) + } + + @Test + fun `buildPromptJson - with model override`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + providerID = "anthropic", + modelID = "claude-4", + ) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains(""""model":{"providerID":"anthropic","modelID":"claude-4"}""")) + } + + @Test + fun `buildPromptJson - with agent`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + agent = "ask", + ) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains(""""agent":"ask"""")) + } + + @Test + fun `buildPromptJson - escapes special characters`() { + val prompt = PromptDto(parts = listOf(PromptPartDto("text", "line1\nline2\t\"quoted\""))) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains("""line1\nline2\t\"quoted\"""")) + } + + // ================================================================ + // buildConfigPartial + // ================================================================ + + @Test + fun `buildConfigPartial - model only`() { + val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(model = "anthropic/claude-4")) + assertEquals("""{"model":"anthropic/claude-4"}""", result) + } + + @Test + fun `buildConfigPartial - agent and temperature`() { + val result = KiloCliDataParser.buildConfigPartial( + ConfigUpdateDto(agent = "code", temperature = 0.7) + ) + assertTrue(result.contains(""""default_agent":"code"""")) + assertTrue(result.contains(""""agent":{"code":{"temperature":0.7}}""")) + } + + @Test + fun `buildConfigPartial - empty update`() { + val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto()) + assertEquals("{}", result) + } + + @Test + fun `buildConfigPartial - temperature without agent defaults to ask`() { + val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(temperature = 0.5)) + assertTrue(result.contains(""""agent":{"ask":{"temperature":0.5}}""")) + } + + // ================================================================ + // Helpers + // ================================================================ + + /** Wrap payload content in a GlobalEvent structure. */ + private fun globalEvent(payload: String): String = + """{"directory":"/tmp","payload":{$payload}}""" +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/ProjectModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt similarity index 78% rename from packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/ProjectModelSerializationTest.kt rename to packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt index 99fc7894a1c..cfa5da9d0a5 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/ProjectModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend +package ai.kilocode.backend.cli import ai.kilocode.jetbrains.api.infrastructure.Serializer import ai.kilocode.jetbrains.api.model.Agent @@ -36,18 +36,30 @@ class ProjectModelSerializationTest { "all": [{ "id": "anthropic", "name": "Anthropic", + "source": "api", "env": ["ANTHROPIC_API_KEY"], + "options": {}, "models": { "claude-4": { "id": "claude-4", + "providerID": "anthropic", "name": "Claude 4", - "release_date": "2025-05-01", - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, + "api": {"id": "anthropic", "url": "", "npm": ""}, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": {"text": true, "audio": false, "image": false, "video": false, "pdf": false}, + "output": {"text": true, "audio": false, "image": false, "video": false, "pdf": false}, + "interleaved": false + }, + "cost": {"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}, "limit": {"context": 200000, "output": 16000}, - "options": {} + "status": "active", + "options": {}, + "headers": {}, + "release_date": "2025-05-01" } } }], @@ -61,9 +73,9 @@ class ProjectModelSerializationTest { val model = obj.all[0].models["claude-4"] assertNotNull(model) assertEquals("Claude 4", model.name) - assertTrue(model.attachment) - assertTrue(model.reasoning) - assertTrue(model.toolCall) + assertTrue(model.capabilities.attachment) + assertTrue(model.capabilities.reasoning) + assertTrue(model.capabilities.toolcall) assertEquals("anthropic/claude-4", obj.default["code"]) assertEquals(listOf("anthropic"), obj.connected) } @@ -74,20 +86,31 @@ class ProjectModelSerializationTest { "all": [{ "id": "free-provider", "name": "Free", + "source": "api", "env": [], + "options": {}, "models": { "free-model": { "id": "free-model", + "providerID": "free-provider", "name": "Free Model", - "release_date": "2025-01-01", - "attachment": false, - "reasoning": false, - "temperature": false, - "tool_call": false, - "isFree": true, - "status": "beta", + "api": {"id": "free-provider", "url": "", "npm": ""}, + "capabilities": { + "temperature": false, + "reasoning": false, + "attachment": false, + "toolcall": false, + "input": {"text": true, "audio": false, "image": false, "video": false, "pdf": false}, + "output": {"text": true, "audio": false, "image": false, "video": false, "pdf": false}, + "interleaved": false + }, + "cost": {"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}, "limit": {"context": 8000, "output": 4000}, - "options": {} + "status": "beta", + "options": {}, + "headers": {}, + "release_date": "2025-01-01", + "isFree": true } } }], @@ -98,7 +121,7 @@ class ProjectModelSerializationTest { val model = obj.all[0].models["free-model"]!! assertEquals(true, model.isFree) assertEquals( - ai.kilocode.jetbrains.api.model.ProviderList200ResponseAllInnerModelsValue.Status.BETA, + ai.kilocode.jetbrains.api.model.Model.Status.BETA, model.status, ) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/SessionModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt similarity index 99% rename from packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/SessionModelSerializationTest.kt rename to packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt index 765433497b1..398a1e4c4aa 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/SessionModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend +package ai.kilocode.backend.cli import ai.kilocode.jetbrains.api.infrastructure.Serializer import ai.kilocode.jetbrains.api.model.Session diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt index 0659cf80b58..885b327be8d 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/FakeCliServer.kt @@ -1,6 +1,6 @@ package ai.kilocode.backend.testing -import ai.kilocode.backend.app.CliServer +import ai.kilocode.backend.cli.CliServer /** * Fake [CliServer] that delegates to a [MockCliServer] instead of diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt index 35f1bc92562..ec56afc29f4 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt @@ -8,6 +8,11 @@ import ai.kilocode.backend.util.KiloLog class TestLog : KiloLog { val messages = mutableListOf() + override fun debug(msg: String) { + synchronized(messages) { messages.add("DEBUG: $msg") } + println("[test] DEBUG: $msg") + } + override fun info(msg: String) { synchronized(messages) { messages.add("INFO: $msg") } println("[test] INFO: $msg") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt similarity index 93% rename from packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendWorkspaceTest.kt rename to packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt index 12147a8997f..f1f6c38ebf4 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.backend +package ai.kilocode.backend.workspace import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService @@ -432,7 +432,9 @@ class KiloBackendWorkspaceTest { "all": [{ "id": "openai", "name": "OpenAI", + "source": "api", "env": [], + "options": {}, "models": {} }], "default": {}, @@ -472,18 +474,30 @@ class KiloBackendWorkspaceTest { "all": [{ "id": "anthropic", "name": "Anthropic", + "source": "api", "env": ["ANTHROPIC_API_KEY"], + "options": {}, "models": { "claude-4": { "id": "claude-4", + "providerID": "anthropic", "name": "Claude 4", - "release_date": "2025-05-01", - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, + "api": {"id": "anthropic", "url": "", "npm": ""}, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": {"text": true, "audio": false, "image": false, "video": false, "pdf": false}, + "output": {"text": true, "audio": false, "image": false, "video": false, "pdf": false}, + "interleaved": false + }, + "cost": {"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}, "limit": {"context": 200000, "output": 16000}, - "options": {} + "status": "active", + "options": {}, + "headers": {}, + "release_date": "2025-05-01" } } }], diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt index 75c1b9f3a5b..a0f1ecc249d 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt @@ -20,6 +20,9 @@ import java.io.File * `JsonElement` for dynamic JSON values. * 7. Empty anyOf wrappers — `anyOf` unions that generate empty classes. * Replaced with `kotlinx.serialization.json.JsonElement`. + * 9. AnyOf union wrappers — `anyOf` unions like `boolean | object` that + * generate paired `Foo` + `FooAnyOf` classes the generator can't flatten. + * Replaced with `kotlinx.serialization.json.JsonElement`. */ abstract class FixGeneratedApiTask : DefaultTask() { @get:OutputDirectory @@ -29,6 +32,7 @@ abstract class FixGeneratedApiTask : DefaultTask() { fun run() { val root = generated.get().asFile fixEmptyWrappers(root) + fixAnyOfUnionWrappers(root) root.walkTopDown().filter { it.extension == "kt" }.forEach { fix(it) } } @@ -58,6 +62,56 @@ abstract class FixGeneratedApiTask : DefaultTask() { } } + /** + * Fix 9: anyOf union wrappers — the generator creates paired `Foo` and + * `FooAnyOf` data classes for `anyOf` unions like `boolean | object`. + * When both classes have identical fields it means the generator couldn't + * flatten the union; neither class can represent all JSON forms, so replace + * them with `kotlinx.serialization.json.JsonElement`. + */ + private fun fixAnyOfUnionWrappers(root: File) { + val models = File(root, "ai/kilocode/jetbrains/api/model") + if (!models.isDirectory) return + + val files = models.listFiles()?.filter { it.extension == "kt" } ?: return + val byName = files.associateBy { it.nameWithoutExtension } + + val field = Regex("""\bval\s+`?(\w+)`?\s*:""") + fun fields(file: File): Set = + field.findAll(file.readText()).map { it.groupValues[1] }.toSet() + + // Collect wrapper pairs where Foo and FooAnyOf have identical fields — + // a sign the generator duplicated one anyOf variant as a wrapper. + val wrappers = mutableListOf() + for ((name, file) in byName) { + if (!name.endsWith("AnyOf")) continue + val parent = name.removeSuffix("AnyOf") + val parentFile = byName[parent] ?: continue + if (fields(file) == fields(parentFile)) { + wrappers.add(name) + wrappers.add(parent) + } + } + if (wrappers.isEmpty()) return + + // Sort longest-first so replacements don't collide (e.g. FooAnyOf before Foo). + wrappers.sortByDescending { it.length } + + for (name in wrappers) File(models, "$name.kt").delete() + + root.walkTopDown().filter { it.extension == "kt" }.forEach { file -> + var text = file.readText() + var changed = false + for (name in wrappers) { + if (!text.contains(name)) continue + text = text.replace(Regex("""import [^\n]*\.$name\n"""), "") + text = text.replace(Regex("""\b$name\b"""), "kotlinx.serialization.json.JsonElement") + changed = true + } + if (changed) file.writeText(text) + } + } + private fun fix(file: File) { var text = file.readText() var changed = false diff --git a/packages/kilo-jetbrains/detekt.yml b/packages/kilo-jetbrains/detekt.yml index e5e889fa4d8..20688d50e8f 100644 --- a/packages/kilo-jetbrains/detekt.yml +++ b/packages/kilo-jetbrains/detekt.yml @@ -7,24 +7,25 @@ # New code must stay within the default limits. Do not raise these # caps; refactor instead. -complexity: - CyclomaticComplexMethod: - active: true - threshold: 15 - LongMethod: - active: true - threshold: 60 - LargeClass: - active: true - threshold: 600 - TooManyFunctions: - active: true - threshold: 15 - ComplexCondition: - active: true - threshold: 4 - LongParameterList: - active: false - NestedBlockDepth: - active: true - threshold: 4 +# That's postponed for one week +#complexity: +# CyclomaticComplexMethod: +# active: true +# threshold: 15 +# LongMethod: +# active: true +# threshold: 60 +# LargeClass: +# active: true +# threshold: 600 +# TooManyFunctions: +# active: true +# threshold: 15 +# ComplexCondition: +# active: true +# threshold: 4 +# LongParameterList: +# active: false +# NestedBlockDepth: +# active: true +# threshold: 4 diff --git a/packages/kilo-jetbrains/frontend/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts index 8643bc6eb94..d2408e8549e 100644 --- a/packages/kilo-jetbrains/frontend/build.gradle.kts +++ b/packages/kilo-jetbrains/frontend/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.intellij.platform.gradle.TestFrameworkType + plugins { alias(libs.plugins.rpc) alias(libs.plugins.kotlin) @@ -12,7 +14,20 @@ dependencies { intellijPlatform { intellijIdea(libs.versions.intellij.platform) bundledModule("intellij.platform.frontend") + testFramework(TestFrameworkType.Platform) } implementation(project(":shared")) + + testImplementation(kotlin("test")) + testImplementation("junit:junit:4.13.2") + testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.11.4") +} + +tasks.test { + // BasePlatformTestCase uses JUnit 3 test naming (test prefix), + // discovered by the vintage engine via JUnit Platform + useJUnitPlatform() + // Ensure JUnit 3/4 tests run via vintage engine + jvmArgs("-Didea.force.use.core.classloader=true") } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloProjectService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloProjectService.kt deleted file mode 100644 index 22914195e8a..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloProjectService.kt +++ /dev/null @@ -1,57 +0,0 @@ -@file:Suppress("UnstableApiUsage") - -package ai.kilocode.client - -import ai.kilocode.rpc.KiloProjectRpcApi -import ai.kilocode.rpc.dto.KiloWorkspaceStateDto -import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto -import com.intellij.openapi.components.Service -import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.project.Project -import fleet.rpc.client.durable -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -/** - * Project-level frontend service that provides reactive access - * to project-scoped data (providers, agents, commands, skills). - * - * Communicates with the backend via [KiloProjectRpcApi]. The flow - * is collected eagerly so that data is available as soon as the - * backend finishes loading. - */ -@Service(Service.Level.PROJECT) -class KiloProjectService( - private val project: Project, - private val cs: CoroutineScope, -) { - companion object { - private val LOG = Logger.getInstance(KiloProjectService::class.java) - private val init = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) - } - - private val directory: String get() = project.basePath ?: "" - - val state: StateFlow = flow { - durable { - KiloProjectRpcApi.getInstance() - .state(directory) - .collect { emit(it) } - } - }.stateIn(cs, SharingStarted.Eagerly, init) - - /** Trigger a full reload of all project data. */ - fun reload() { - cs.launch { - try { - durable { KiloProjectRpcApi.getInstance().reload(directory) } - } catch (e: Exception) { - LOG.warn("project data reload failed", e) - } - } - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloSessionService.kt deleted file mode 100644 index 1dee6d12d99..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloSessionService.kt +++ /dev/null @@ -1,115 +0,0 @@ -@file:Suppress("UnstableApiUsage") - -package ai.kilocode.client - -import ai.kilocode.rpc.KiloSessionRpcApi -import ai.kilocode.rpc.dto.SessionDto -import ai.kilocode.rpc.dto.SessionStatusDto -import com.intellij.openapi.components.Service -import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.project.Project -import fleet.rpc.client.durable -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -/** - * Project-level frontend service for session management. - * - * Provides session CRUD, active session tracking, and live - * status updates via [KiloSessionRpcApi]. All operations are - * scoped to the project's [directory] by default, with support - * for per-session worktree directory overrides. - */ -@Service(Service.Level.PROJECT) -class KiloSessionService( - private val project: Project, - private val cs: CoroutineScope, -) { - companion object { - private val LOG = Logger.getInstance(KiloSessionService::class.java) - } - - private val directory: String get() = project.basePath ?: "" - - private val _sessions = MutableStateFlow>(emptyList()) - val sessions: StateFlow> = _sessions.asStateFlow() - - private val _active = MutableStateFlow(null) - val active: StateFlow = _active.asStateFlow() - - /** Live session status map from SSE events. */ - val statuses: StateFlow> = flow { - durable { - KiloSessionRpcApi.getInstance() - .statuses() - .collect { emit(it) } - } - }.stateIn(cs, SharingStarted.Eagerly, emptyMap()) - - /** Refresh the session list from the server. */ - fun refresh() { - cs.launch { - try { - val result = durable { KiloSessionRpcApi.getInstance().list(directory) } - _sessions.value = result.sessions - } catch (e: Exception) { - LOG.warn("session list failed", e) - } - } - } - - /** Create a new session and make it active. */ - fun create() { - cs.launch { - try { - val session = durable { KiloSessionRpcApi.getInstance().create(directory) } - _active.value = session - refresh() - } catch (e: Exception) { - LOG.warn("session create failed", e) - } - } - } - - /** Select an existing session as active. */ - fun select(id: String) { - cs.launch { - try { - val session = durable { KiloSessionRpcApi.getInstance().get(id, directory) } - _active.value = session - } catch (e: Exception) { - LOG.warn("session select failed", e) - } - } - } - - /** Delete a session. Clears active if it was the deleted one. */ - fun delete(id: String) { - cs.launch { - try { - durable { KiloSessionRpcApi.getInstance().delete(id, directory) } - if (_active.value?.id == id) _active.value = null - refresh() - } catch (e: Exception) { - LOG.warn("session delete failed", e) - } - } - } - - /** Register a worktree directory override for a session. */ - fun setDirectory(id: String, dir: String) { - cs.launch { - try { - durable { KiloSessionRpcApi.getInstance().setDirectory(id, dir) } - } catch (e: Exception) { - LOG.warn("setDirectory failed", e) - } - } - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index dad0fd07170..f6bbf13166e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -1,23 +1,33 @@ package ai.kilocode.client +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.session.SessionUi +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.content.ContentFactory import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** - * Creates the Kilo Code tool window content. + * Creates the Kilo Code tool window with a single [SessionUi]. * - * Wires [KiloAppService] and [KiloProjectService] into a - * [KiloWelcomeUi] panel that shows app + workspace initialization - * status. All UI logic lives in [KiloWelcomeUi]. + * Resolves the project directory through the backend (handles split-mode + * where `project.basePath` is a synthetic frontend path) before creating + * the workspace. The tool window shows a loading state until resolution + * completes. */ -class KiloToolWindowFactory : ToolWindowFactory { +class KiloToolWindowFactory : ToolWindowFactory, DumbAware { companion object { private val LOG = Logger.getInstance(KiloToolWindowFactory::class.java) @@ -25,20 +35,44 @@ class KiloToolWindowFactory : ToolWindowFactory { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { try { + val workspaces = service() + val sessions = project.service() val app = service() - val workspace = project.service() - val scope = CoroutineScope(SupervisorJob()) - val ui = KiloWelcomeUi(app, workspace, scope) + val cs = CoroutineScope(SupervisorJob()) + val hint = project.basePath ?: "" - val content = ContentFactory.getInstance().createContent(ui, "", false) - content.setDisposer(ui) + cs.launch { + val dir = workspaces.resolveProjectDirectory(hint) + val workspace = workspaces.workspace(dir) + withContext(Dispatchers.Main) { + setup(project, toolWindow, workspace, sessions, app, cs) + } + } + } catch (e: Exception) { + LOG.error("Failed to create Kilo tool window content", e) + } + } + + private fun setup( + project: Project, + toolWindow: ToolWindow, + workspace: Workspace, + sessions: KiloSessionService, + app: KiloAppService, + cs: CoroutineScope, + ) { + try { + val chat = SessionUi(project, workspace, sessions, app, cs) + val content = ContentFactory.getInstance() + .createContent(chat, "", false) + content.setDisposer(chat) toolWindow.contentManager.addContent(content) ActionManager.getInstance().getAction("Kilo.Settings")?.let { toolWindow.setTitleActions(listOf(it)) } } catch (e: Exception) { - LOG.error("Failed to create Kilo tool window content", e) + LOG.error("Failed to set up Kilo tool window content", e) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt index dbb933509b6..242498d9b59 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.actions -import ai.kilocode.client.KiloAppService +import ai.kilocode.client.app.KiloAppService import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt index aa29c503307..f69c8c9668d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.actions -import ai.kilocode.client.KiloAppService +import ai.kilocode.client.app.KiloAppService import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt index afe4df222c5..7f808f158ae 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.actions -import ai.kilocode.client.KiloAppService +import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.actionSystem.AnAction diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt similarity index 78% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloAppService.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 6d5122a8fac..da592dd32a5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -1,6 +1,6 @@ @file:Suppress("UnstableApiUsage") -package ai.kilocode.client +package ai.kilocode.client.app import ai.kilocode.rpc.KiloAppRpcApi import ai.kilocode.rpc.dto.HealthDto @@ -22,12 +22,15 @@ import kotlinx.coroutines.launch * * Communicates with the backend via [KiloAppRpcApi]. All operations * are app-scoped — no project context is needed. - * - * Callers of [watch] are responsible for scheduling UI updates on - * the EDT and converting [KiloAppStateDto] to display text. */ @Service(Service.Level.APP) -class KiloAppService(private val cs: CoroutineScope) { +class KiloAppService internal constructor( + private val cs: CoroutineScope, + private val rpc: KiloAppRpcApi?, +) { + /** Platform constructor — resolves RPC from the service container. */ + constructor(cs: CoroutineScope) : this(cs, null) + companion object { private val LOG = Logger.getInstance(KiloAppService::class.java) private val init = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) @@ -40,28 +43,31 @@ class KiloAppService(private val cs: CoroutineScope) { var version: String? = null private set - private val _state = MutableStateFlow(init) + internal val _state = MutableStateFlow(init) val state: StateFlow = _state.asStateFlow() + // ------ RPC helper ------ + + private suspend fun call(block: suspend KiloAppRpcApi.() -> T): T { + val api = rpc + return if (api != null) block(api) else durable { block(KiloAppRpcApi.getInstance()) } + } + + // ------ Lifecycle ------ + fun connect() { if (!started.compareAndSet(false, true)) return + cs.launch { call { connect() } } cs.launch { - durable { - KiloAppRpcApi.getInstance().connect() - } - } - cs.launch { - durable { - KiloAppRpcApi.getInstance() - .state() - .collect { _state.value = it } - } + val api = rpc + if (api != null) api.state().collect { _state.value = it } + else durable { KiloAppRpcApi.getInstance().state().collect { _state.value = it } } } } /** One-shot health check. Returns null on failure. */ suspend fun health(): HealthDto? = try { - durable { KiloAppRpcApi.getInstance().health() } + call { health() } } catch (e: Exception) { LOG.warn("health check failed", e) null @@ -72,7 +78,7 @@ class KiloAppService(private val cs: CoroutineScope) { LOG.info("restart: resetting state and sending RPC") started.set(false) version = null - durable { KiloAppRpcApi.getInstance().restart() } + call { restart() } LOG.info("restart: RPC returned — backend restart complete") } @@ -81,7 +87,7 @@ class KiloAppService(private val cs: CoroutineScope) { LOG.info("reinstall: resetting state and sending RPC") started.set(false) version = null - durable { KiloAppRpcApi.getInstance().reinstall() } + call { reinstall() } LOG.info("reinstall: RPC returned — backend reinstall complete") } @@ -113,9 +119,6 @@ class KiloAppService(private val cs: CoroutineScope) { /** * Collect app state changes and invoke [fn] for each update. - * - * The callback receives raw [KiloAppStateDto] — the caller is - * responsible for converting to display text and scheduling on the EDT. */ fun watch(fn: (KiloAppStateDto) -> Unit): Job { return cs.launch { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt new file mode 100644 index 00000000000..45c81c2e2c9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -0,0 +1,146 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.client.app + +import ai.kilocode.rpc.KiloSessionRpcApi +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.PromptPartDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionStatusDto +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import fleet.rpc.client.durable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Project-level frontend service for session management. + * + * Stateless with respect to "active session" — callers pass explicit + * session IDs. [ai.kilocode.client.session.model.SessionModel] owns the + * active session concept. + */ +@Service(Service.Level.PROJECT) +class KiloSessionService internal constructor( + private val project: Project, + private val cs: CoroutineScope, + private val rpc: KiloSessionRpcApi?, +) { + /** Platform constructor — resolves RPC from the service container. */ + constructor(project: Project, cs: CoroutineScope) : this(project, cs, null) + + companion object { + private val LOG = Logger.getInstance(KiloSessionService::class.java) + } + + private val _sessions = MutableStateFlow>(emptyList()) + val sessions: StateFlow> = _sessions.asStateFlow() + + /** Live session status map from SSE events. */ + val statuses: StateFlow> = + stream { statuses() }.stateIn(cs, SharingStarted.Eagerly, emptyMap()) + + // ------ RPC helpers ------ + + private suspend fun call(block: suspend KiloSessionRpcApi.() -> T): T { + val api = rpc + return if (api != null) block(api) else durable { block(KiloSessionRpcApi.getInstance()) } + } + + private fun stream(block: suspend KiloSessionRpcApi.() -> Flow): Flow = flow { + val api = rpc + if (api != null) block(api).collect { emit(it) } + else durable { block(KiloSessionRpcApi.getInstance()).collect { emit(it) } } + } + + // ------ Session CRUD ------ + + /** Refresh the session list from the server. */ + fun refresh(dir: String) { + cs.launch { + try { + val result = call { list(dir) } + _sessions.value = result.sessions + } catch (e: Exception) { + LOG.warn("session list failed", e) + } + } + } + + /** Create a new session. Caller awaits the result. */ + suspend fun create(dir: String): SessionDto { + LOG.info("create: dir=$dir") + val session = call { create(dir) } + LOG.info("create: id=${session.id}") + refresh(dir) + return session + } + + /** Delete a session. */ + fun delete(id: String, dir: String) { + cs.launch { + try { + call { delete(id, dir) } + refresh(dir) + } catch (e: Exception) { + LOG.warn("session delete failed", e) + } + } + } + + /** Register a worktree directory override for a session. */ + fun setDirectory(id: String, dir: String) { + cs.launch { + try { + call { setDirectory(id, dir) } + } catch (e: Exception) { + LOG.warn("setDirectory failed", e) + } + } + } + + // ------ Chat ops (explicit session ID) ------ + + /** Send a text prompt to a session. */ + suspend fun prompt(id: String, dir: String, text: String) { + LOG.info("prompt: session=$id, dir=$dir, text=${text.take(80)}") + val dto = PromptDto(parts = listOf(PromptPartDto(type = "text", text = text))) + call { prompt(id, dir, dto) } + LOG.info("prompt: RPC returned successfully") + } + + /** Abort ongoing processing for a session. */ + suspend fun abort(id: String, dir: String) { + call { abort(id, dir) } + } + + /** Load message history for a session. */ + suspend fun messages(id: String, dir: String): List = + call { messages(id, dir) } + + /** Subscribe to streaming chat events for a session. */ + fun events(id: String, dir: String): Flow { + val api = rpc + return if (api != null) flow { + api.events(id, dir).collect { emit(it) } + } else flow { + durable { KiloSessionRpcApi.getInstance().events(id, dir).collect { emit(it) } } + } + } + + /** Update config (model, agent/mode, temperature). */ + suspend fun updateConfig(dir: String, config: ConfigUpdateDto) { + call { updateConfig(dir, config) } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt new file mode 100644 index 00000000000..b0c065d2941 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -0,0 +1,101 @@ +@file:Suppress("UnstableApiUsage") + +package ai.kilocode.client.app + +import ai.kilocode.rpc.KiloWorkspaceRpcApi +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import fleet.rpc.client.durable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * App-level service that manages [Workspace] instances keyed by directory. + * + * Multiple projects sharing the same directory share the same [Workspace] + * and its state flow. Directory resolution handles split-mode where the + * frontend sees a synthetic path that must be resolved to the real path + * on the backend host. + */ +@Service(Service.Level.APP) +class KiloWorkspaceService internal constructor( + private val cs: CoroutineScope, + private val rpc: KiloWorkspaceRpcApi?, +) { + /** Platform constructor — resolves RPC from the service container. */ + constructor(cs: CoroutineScope) : this(cs, null) + + companion object { + private val LOG = Logger.getInstance(KiloWorkspaceService::class.java) + private val INIT = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) + } + + private val workspaces = ConcurrentHashMap() + + // ------ RPC helpers ------ + + private suspend fun call(block: suspend KiloWorkspaceRpcApi.() -> T): T { + val api = rpc + return if (api != null) block(api) else durable { block(KiloWorkspaceRpcApi.getInstance()) } + } + + private fun stream(block: suspend KiloWorkspaceRpcApi.() -> Flow): Flow = flow { + val api = rpc + if (api != null) block(api).collect { emit(it) } + else durable { block(KiloWorkspaceRpcApi.getInstance()).collect { emit(it) } } + } + + // ------ Public API ------ + + /** + * Get or create a [Workspace] for [directory]. + * + * Synchronous — returns immediately. The workspace's [Workspace.state] + * flow starts streaming lazily when first collected. Multiple callers + * for the same directory share the same instance. + */ + fun workspace(directory: String): Workspace { + return workspaces.getOrPut(directory) { + LOG.info("Creating workspace for $directory") + val state = stream { state(directory) } + .stateIn(cs, SharingStarted.Eagerly, INIT) + Workspace(directory, state) + } + } + + /** + * Resolve the real project directory from a hint path. + * + * In split-mode the frontend sees a synthetic path (e.g. + * `/home/.cache/JetBrains/RemoteDev/...`). The backend resolves + * it to the actual project root on the host. + */ + suspend fun resolveProjectDirectory(hint: String): String { + return try { + val resolved = call { resolveProjectDirectory(hint) } + LOG.info("Resolved project directory: hint=$hint → $resolved") + resolved + } catch (e: Exception) { + LOG.warn("Failed to resolve directory, falling back to hint=$hint", e) + hint + } + } + + /** Trigger a full reload of workspace data for [directory]. */ + fun reload(directory: String) { + cs.launch { + try { + call { reload(directory) } + } catch (e: Exception) { + LOG.warn("workspace reload failed for $directory", e) + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt new file mode 100644 index 00000000000..7f7cd961814 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt @@ -0,0 +1,16 @@ +package ai.kilocode.client.app + +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import kotlinx.coroutines.flow.StateFlow + +/** + * A workspace for a single directory. Mirrors the CLI concept of a + * workspace — a directory with its providers, agents, commands, skills. + * + * Immutable reference — [state] flows internally as the workspace loads. + * Lifecycle managed by [KiloWorkspaceService]. + */ +class Workspace( + val directory: String, + val state: StateFlow, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt new file mode 100644 index 00000000000..2a9aec5e9e3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -0,0 +1,190 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.model.SessionEvent +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.ui.LabelPicker +import ai.kilocode.client.session.ui.MessageListPanel +import ai.kilocode.client.session.ui.PromptPanel +import ai.kilocode.client.session.ui.StatusPanel +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import kotlinx.coroutines.CoroutineScope +import java.awt.BorderLayout +import java.awt.CardLayout +import javax.swing.JPanel + +/** + * Main chat panel — reacts to [SessionModel] events. + * + * Uses [CardLayout] in the center to switch between the empty panel + * (shown before the first prompt) and the scrollable message list. + * + * All business logic (app/workspace watching, session lifecycle, event + * handling, status computation) lives in [SessionModel]. Welcome + * rendering lives in [ai.kilocode.client.session.ui.StatusPanel]. This class handles layout, prompt + * wiring, message list updates, card switching, picker population, + * busy state, and scrolling. + */ +class SessionUi( + project: Project, + workspace: Workspace, + sessions: KiloSessionService, + app: KiloAppService, + cs: CoroutineScope, +) : JPanel(BorderLayout()), Disposable { + + companion object { + private const val STATUS = "status" + private const val MESSAGES = "messages" + } + + private val model = SessionModel(this, null, sessions, workspace, app, cs) + private val status = StatusPanel(this, model) + private val messages = MessageListPanel() + + private val cards = CardLayout() + private val center = JPanel(cards) + + private val scroll = JBScrollPane(messages).apply { + border = JBUI.Borders.empty() + verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED + horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER + } + + private val prompt = PromptPanel( + project = project, + onSend = { text -> send(text) }, + onAbort = { model.abort() }, + ) + + init { + // Layout + center.add(status, STATUS) + center.add(scroll, MESSAGES) + cards.show(center, STATUS) + + add(center, BorderLayout.CENTER) + add(prompt, BorderLayout.SOUTH) + + // Wire picker callbacks via typed model methods + prompt.mode.onSelect = { item -> + model.selectAgent(item.id) + } + prompt.model.onSelect = { item -> + val group = item.group + if (group != null) { + model.selectModel(group, item.id) + } + } + + // React to model events — no coroutines, pure EDT + model.addListener(this) { event -> + when (event) { + is SessionEvent.MessageAdded -> { + val msg = model.chat.message(event.id) ?: return@addListener + messages.addMessage(msg.info) + refreshMessages() + } + + is SessionEvent.MessageRemoved -> { + messages.removeMessage(event.id) + refreshMessages() + } + + is SessionEvent.PartUpdated -> { + val part = model.chat.part(event.messageId, event.partId) ?: return@addListener + messages.updatePartText(event.messageId, event.partId, part.text.toString()) + refreshMessages() + } + + is SessionEvent.PartDelta -> { + messages.appendDelta(event.messageId, event.partId, event.delta) + refreshMessages() + } + + is SessionEvent.StatusChanged -> { + messages.setStatus(event.text) + refreshMessages() + } + + is SessionEvent.Error -> { + messages.addError(event.message) + refreshMessages() + } + + is SessionEvent.HistoryLoaded -> { + messages.clear() + for (msg in model.chat.messages()) { + messages.addMessage(msg.info) + for ((partId, part) in msg.parts) { + if (part.dto.type == "text" && part.text.isNotEmpty()) { + messages.updatePartText(msg.info.id, partId, part.text.toString()) + } + } + } + refreshMessages() + } + + is SessionEvent.Cleared -> { + messages.clear() + refreshMessages() + } + + is SessionEvent.WorkspaceReady -> { + val c = model.chat + prompt.mode.setItems( + c.agents.map { LabelPicker.Item(it.name, it.display) }, + c.agent, + ) + val items = c.models.map { LabelPicker.Item(it.id, it.display, it.provider) } + // chat.model is "provider/modelId", picker items use modelId only. + // Find the matching item and pass its id for selection. + val selected = c.model?.let { full -> + items.firstOrNull { "${it.group}/${it.id}" == full }?.id + } + prompt.model.setItems(items, selected) + prompt.setReady(c.ready) + } + + is SessionEvent.ViewChanged -> { + cards.show(center, if (event.show) MESSAGES else STATUS) + } + + is SessionEvent.BusyChanged -> { + prompt.setBusy(event.busy) + } + + is SessionEvent.AppChanged, + is SessionEvent.WorkspaceChanged -> { + // Handled by StatusPanel + } + } + } + } + + private fun send(text: String) { + if (text.isBlank()) return + model.prompt(text) + prompt.clear() + } + + private fun refreshMessages() { + messages.revalidate() + messages.repaint() + scrollToBottom() + } + + private fun scrollToBottom() { + val bar = scroll.verticalScrollBar + bar.value = bar.maximum + } + + override fun dispose() { + // All children (status, model) disposed by Disposer + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionEvent.kt new file mode 100644 index 00000000000..f45903f0555 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionEvent.kt @@ -0,0 +1,45 @@ +package ai.kilocode.client.session.model + +/** + * Change events fired by [SessionModel] on the EDT. + * + * Events carry IDs so the UI knows **which** message/part changed. + * The UI can read full data from [SessionState] directly (safe — same + * EDT thread). [PartDelta] also carries the delta string so the + * view can append efficiently without reading the whole text. + */ +sealed class SessionEvent { + + // Message lifecycle + data class MessageAdded(val id: String) : SessionEvent() + data class MessageRemoved(val id: String) : SessionEvent() + + // Part changes + data class PartUpdated(val messageId: String, val partId: String) : SessionEvent() + data class PartDelta(val messageId: String, val partId: String, val delta: String) : SessionEvent() + + // Session state + data class StatusChanged(val text: String?) : SessionEvent() + data class BusyChanged(val busy: Boolean) : SessionEvent() + data class Error(val message: String) : SessionEvent() + + // Bulk operations + data object HistoryLoaded : SessionEvent() + data object Cleared : SessionEvent() + + // App + workspace lifecycle (every state transition) + data object AppChanged : SessionEvent() + data object WorkspaceChanged : SessionEvent() + + // Workspace ready (pickers populated) + data object WorkspaceReady : SessionEvent() + data class ViewChanged(val show: Boolean) : SessionEvent() +} + +/** + * Listener for [SessionEvent]s fired by [SessionModel]. + * All callbacks are guaranteed to run on the EDT. + */ +fun interface SessionModelListener { + fun onEvent(event: SessionEvent) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt new file mode 100644 index 00000000000..74c78e1f2e7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -0,0 +1,350 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * Session lifecycle controller for a single session. + * + * Accepts an optional [id] — if non-null, loads that session immediately. + * If null, lazily creates a session on the first [prompt] call. This + * ensures event subscription happens *before* the prompt is sent, + * eliminating race conditions. + * + * Owns [SessionState] and the listener list. All model mutations and + * listener notifications happen on the EDT — [fire] auto-dispatches + * via `invokeLater` when called from a background thread. + */ +class SessionModel( + parent: Disposable, + id: String?, + private val sessions: KiloSessionService, + private val workspace: Workspace, + private val app: KiloAppService, + private val cs: CoroutineScope, +) : Disposable { + + companion object { + private val LOG = Logger.getInstance(SessionModel::class.java) + } + + init { + Disposer.register(parent, this) + } + + val chat = SessionState() + + private val listeners = mutableListOf() + + /** The session ID owned by this model. Null until created or passed in. */ + private var sessionId: String? = id + + /** Resolved project directory for RPC calls. */ + private val directory: String get() = workspace.directory + + // Status computation state (EDT-only) + private var partType: String? = null + private var tool: String? = null + private var busy: Boolean = false + + // Coroutine job for the current event subscription + private var eventJob: Job? = null + + // --- Listener management (EDT) --- + + /** + * Register a listener whose lifetime is tied to [parent]. + * When [parent] is disposed the listener is auto-removed. + */ + fun addListener(parent: Disposable, listener: SessionModelListener) { + listeners.add(listener) + Disposer.register(parent) { listeners.remove(listener) } + } + + // --- Actions (called from EDT) --- + + /** + * Send a prompt. If no session exists, creates one first, + * subscribes to events, then sends the prompt — all in one + * coroutine to avoid race conditions. + */ + fun prompt(text: String) { + showMessages() + cs.launch { + try { + val id = sessionId ?: run { + val session = sessions.create(directory) + sessionId = session.id + subscribeEvents() + session.id + } + sessions.prompt(id, directory, text) + } catch (e: Exception) { + LOG.warn("prompt failed", e) + edt { + fire(SessionEvent.Error(e.message ?: KiloBundle.message("session.error.prompt"))) + fire(SessionEvent.BusyChanged(false)) + } + } + } + } + + fun abort() { + val id = sessionId ?: return + cs.launch { + try { + sessions.abort(id, directory) + } catch (e: Exception) { + LOG.warn("abort failed", e) + } + } + } + + fun selectAgent(name: String) { + chat.agent = name + cs.launch { + try { + sessions.updateConfig(directory, ConfigUpdateDto(agent = name)) + } catch (e: Exception) { + LOG.warn("selectAgent failed", e) + } + } + fire(SessionEvent.WorkspaceReady) + } + + fun selectModel(provider: String, id: String) { + chat.model = "$provider/$id" + cs.launch { + try { + sessions.updateConfig(directory, ConfigUpdateDto(model = "$provider/$id")) + } catch (e: Exception) { + LOG.warn("selectModel failed", e) + } + } + fire(SessionEvent.WorkspaceReady) + } + + // --- Internal: coroutine → EDT bridge --- + + init { + // If we have a session ID, load it immediately + if (sessionId != null) { + loadHistory() + subscribeEvents() + } + + // Watch session statuses for busy/idle + cs.launch { + sessions.statuses.collect { statuses -> + val id = sessionId ?: return@collect + val st = statuses[id] + edt { fire(SessionEvent.BusyChanged(st?.type == "busy")) } + } + } + + // Watch app lifecycle state + app.connect() + cs.launch { + app.state.collect { state -> + if (state.status == KiloAppStatusDto.READY) app.fetchVersionAsync() + edt { + chat.app = state + chat.version = app.version + fire(SessionEvent.AppChanged) + } + } + } + + // Watch workspace state for providers/agents and lifecycle + cs.launch { + workspace.state.collect { state -> + edt { + chat.workspace = state + fire(SessionEvent.WorkspaceChanged) + + if (state.status == KiloWorkspaceStatusDto.READY) { + chat.agents = state.agents?.agents?.map { + AgentItem(it.name, it.displayName ?: it.name) + } ?: emptyList() + + chat.models = state.providers?.let { providers -> + providers.providers + .filter { it.id in providers.connected } + .flatMap { provider -> + provider.models.map { (id, info) -> + ModelItem(id, info.name, provider.id) + } + } + } ?: emptyList() + + if (chat.agent == null) { + chat.agent = state.agents?.default + } + if (chat.model == null) { + chat.model = state.providers?.defaults?.entries?.firstOrNull()?.value + } + + chat.ready = true + fire(SessionEvent.WorkspaceReady) + } + } + } + } + } + + private fun loadHistory() { + val id = sessionId ?: return + cs.launch { + try { + val history = sessions.messages(id, directory) + edt { + chat.load(history) + if (!chat.isEmpty()) showMessages() + fire(SessionEvent.HistoryLoaded) + } + } catch (e: Exception) { + LOG.warn("loadHistory failed", e) + } + } + } + + private fun subscribeEvents() { + val id = sessionId ?: return + eventJob?.cancel() + eventJob = cs.launch { + sessions.events(id, directory).collect { event -> + edt { handle(event) } + } + } + } + + private fun handle(event: ChatEventDto) { + when (event) { + is ChatEventDto.MessageUpdated -> { + chat.addMessage(event.info) + showMessages() + fire(SessionEvent.MessageAdded(event.info.id)) + } + + is ChatEventDto.PartUpdated -> { + partType = event.part.type + tool = event.part.tool + chat.updatePart(event.part.messageID, event.part) + if (busy) { + fire(SessionEvent.StatusChanged(status())) + } + if (event.part.type == "text" && event.part.text != null) { + fire(SessionEvent.PartUpdated(event.part.messageID, event.part.id)) + } + } + + is ChatEventDto.PartDelta -> { + if (event.field == "text") { + chat.appendDelta(event.messageID, event.partID, event.delta) + fire(SessionEvent.PartDelta(event.messageID, event.partID, event.delta)) + } + } + + is ChatEventDto.TurnOpen -> { + partType = null + tool = null + busy = true + fire(SessionEvent.StatusChanged(KiloBundle.message("session.status.considering"))) + fire(SessionEvent.BusyChanged(true)) + } + + is ChatEventDto.TurnClose -> { + partType = null + tool = null + busy = false + fire(SessionEvent.StatusChanged(null)) + fire(SessionEvent.BusyChanged(false)) + } + + is ChatEventDto.Error -> { + val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") + busy = false + fire(SessionEvent.Error(msg)) + fire(SessionEvent.StatusChanged(null)) + fire(SessionEvent.BusyChanged(false)) + } + + is ChatEventDto.MessageRemoved -> { + chat.removeMessage(event.messageID) + fire(SessionEvent.MessageRemoved(event.messageID)) + } + } + } + + // --- View switching (EDT) --- + + private fun showMessages() { + if (!chat.showMessages) { + chat.showMessages = true + fire(SessionEvent.ViewChanged(true)) + } + } + + private fun hideMessages() { + if (chat.showMessages) { + chat.showMessages = false + fire(SessionEvent.ViewChanged(false)) + } + } + + /** + * Compute a human-readable status from the last streaming part. + */ + private fun status(): String = when (partType) { + "reasoning" -> KiloBundle.message("session.status.thinking") + "text" -> KiloBundle.message("session.status.writing") + "tool" -> when (tool) { + "task" -> KiloBundle.message("session.status.delegating") + "todowrite", "todoread" -> KiloBundle.message("session.status.planning") + "read" -> KiloBundle.message("session.status.gathering") + "glob", "grep", "list" -> KiloBundle.message("session.status.searching.codebase") + "webfetch", "websearch", "codesearch" -> KiloBundle.message("session.status.searching.web") + "edit", "write" -> KiloBundle.message("session.status.editing") + "bash" -> KiloBundle.message("session.status.commands") + else -> KiloBundle.message("session.status.considering") + } + else -> KiloBundle.message("session.status.considering") + } + + /** + * Notify all listeners. If called from the EDT, listeners run + * immediately. If called from a background thread, the notification + * is dispatched via `invokeLater`. + */ + private fun fire(event: SessionEvent) { + val application = ApplicationManager.getApplication() + if (application.isDispatchThread) { + for (l in listeners) l.onEvent(event) + } else { + application.invokeLater { for (l in listeners) l.onEvent(event) } + } + } + + private fun edt(block: () -> Unit) { + ApplicationManager.getApplication().invokeLater(block) + } + + override fun dispose() { + eventJob?.cancel() + cs.cancel() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt new file mode 100644 index 00000000000..69effbcb9dc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt @@ -0,0 +1,121 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto + +/** + * Pure data holder for the active session's messages, parts, and + * workspace state (agents, models, selection). + * + * **EDT-only access** — no synchronization. [SessionModel] guarantees + * all reads and writes happen on the EDT. + */ +class SessionState { + + private val messages = LinkedHashMap() + + // --- App lifecycle state (set by SessionModel, read by EmptyChatUi) --- + + var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) + var version: String? = null + + // --- Workspace state (set by SessionModel, read by UI) --- + + var workspace: KiloWorkspaceStateDto = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) + var agents: List = emptyList() + var models: List = emptyList() + var agent: String? = null + var model: String? = null + var ready: Boolean = false + var showMessages: Boolean = false + + // --- Read --- + + fun message(id: String): MessageData? = messages[id] + + fun messages(): Collection = messages.values + + fun part(messageId: String, partId: String): PartData? = + messages[messageId]?.parts?.get(partId) + + fun isEmpty(): Boolean = messages.isEmpty() + + // --- Write (called by SessionModel on EDT) --- + + /** + * Add a message. Returns false if the message already exists. + */ + fun addMessage(info: MessageDto): Boolean { + if (messages.containsKey(info.id)) return false + messages[info.id] = MessageData(info, LinkedHashMap()) + return true + } + + /** + * Remove a message by ID. Returns false if not found. + */ + fun removeMessage(id: String): Boolean = + messages.remove(id) != null + + /** + * Create or replace a part entry and set its text from [PartDto.text]. + */ + fun updatePart(messageId: String, part: PartDto) { + val msg = messages[messageId] ?: return + val text = StringBuilder(part.text ?: "") + msg.parts[part.id] = PartData(part, text) + } + + /** + * Append a text delta to an existing part. Creates the part if missing. + */ + fun appendDelta(messageId: String, partId: String, delta: String) { + val msg = messages[messageId] ?: return + val existing = msg.parts[partId] + if (existing != null) { + existing.text.append(delta) + } else { + msg.parts[partId] = PartData( + PartDto(id = partId, sessionID = "", messageID = messageId, type = "text"), + StringBuilder(delta), + ) + } + } + + /** + * Bulk-load message history from RPC DTOs. Clears existing data first. + */ + fun load(history: List) { + messages.clear() + for (msg in history) { + val parts = LinkedHashMap() + for (part in msg.parts) { + parts[part.id] = PartData(part, StringBuilder(part.text ?: "")) + } + messages[msg.info.id] = MessageData(msg.info, parts) + } + } + + fun clear() { + messages.clear() + } +} + +data class MessageData( + val info: MessageDto, + val parts: LinkedHashMap, +) + +class PartData( + val dto: PartDto, + val text: StringBuilder, +) + +data class AgentItem(val name: String, val display: String) + +data class ModelItem(val id: String, val display: String, val provider: String) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LabelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LabelPicker.kt new file mode 100644 index 00000000000..1280e9d7abf --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LabelPicker.kt @@ -0,0 +1,94 @@ +package ai.kilocode.client.session.ui + +import com.intellij.icons.AllIcons +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.ListPopup +import com.intellij.openapi.ui.popup.PopupShowOptions +import com.intellij.openapi.ui.popup.PopupStep +import com.intellij.openapi.ui.popup.util.BaseListPopupStep +import com.intellij.ui.JBColor +import com.intellij.ui.RoundedLineBorder +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.Cursor +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.Icon + +/** + * Clickable label-style dropdown picker with a rounded outline. + * + * Shows the selected item's display text with an up-arrow. On click, + * opens a list popup above the picker. Disabled (greyed out, not + * clickable) when no items are loaded. + */ +class LabelPicker : JBLabel() { + + data class Item(val id: String, val display: String, val group: String? = null) { + override fun toString() = display + } + + var onSelect: (Item) -> Unit = {} + + private var items: List = emptyList() + private var selected: Item? = null + + init { + border = JBUI.Borders.compound( + RoundedLineBorder(JBColor.border(), JBUI.scale(6)), + JBUI.Borders.empty(2, 8), + ) + isEnabled = false + text = " " + + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (!isEnabled || items.isEmpty()) return + showPopup() + } + }) + } + + fun setItems(values: List, default: String? = null) { + items = values + selected = if (default != null) values.firstOrNull { it.id == default } else values.firstOrNull() + refresh() + } + + fun select(id: String) { + selected = items.firstOrNull { it.id == id } + refresh() + } + + private fun refresh() { + if (items.isEmpty()) { + isEnabled = false + text = " " + cursor = Cursor.getDefaultCursor() + return + } + val display = selected?.display ?: items.firstOrNull()?.display ?: "" + text = "$display ▴" + isEnabled = true + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + } + + private fun showPopup() { + val step = object : BaseListPopupStep("", items) { + override fun getTextFor(value: Item) = value.display + + override fun getIconFor(value: Item): Icon? = + if (value.id == selected?.id) AllIcons.Actions.Checked else null + + override fun onChosen(value: Item, final: Boolean): PopupStep<*>? { + selected = value + refresh() + onSelect(value) + return FINAL_CHOICE + } + } + + val popup: ListPopup = JBPopupFactory.getInstance().createListPopup(step) + popup.show(PopupShowOptions.aboveComponent(this)) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListPanel.kt new file mode 100644 index 00000000000..1f8cb192078 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListPanel.kt @@ -0,0 +1,165 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.rpc.dto.MessageDto +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.FlowLayout +import javax.swing.BoxLayout +import javax.swing.JPanel +import javax.swing.JTextArea +import javax.swing.border.MatteBorder + +/** + * Scrollable panel displaying chat messages aligned to the top, + * with an optional animated status indicator at the bottom. + * + * Inner panel uses [BoxLayout.Y_AXIS] for stacking, wrapped in a + * [BorderLayout.NORTH] so messages stay top-aligned when the scroll + * viewport is taller than the content. + */ +class MessageListPanel : JPanel(BorderLayout()) { + + private val panels = LinkedHashMap() + + private val inner = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + border = JBUI.Borders.empty(4, 8) + } + + private val statusLabel = JBLabel().apply { + foreground = UIUtil.getContextHelpForeground() + } + + private val status = JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), 0)).apply { + isOpaque = false + isVisible = false + border = JBUI.Borders.empty(6, 0) + alignmentX = LEFT_ALIGNMENT + add(JBLabel(AnimatedIcon.Default())) + add(statusLabel) + } + + init { + isOpaque = true + background = UIUtil.getPanelBackground() + inner.add(status) + add(inner, BorderLayout.NORTH) + } + + fun addMessage(info: MessageDto) { + if (panels.containsKey(info.id)) return + val block = MessageBlock(info) + panels[info.id] = block + // Insert before the status row (which is always last) + inner.add(block, inner.componentCount - 1) + revalidate() + repaint() + } + + fun updatePartText(messageID: String, partID: String, text: String) { + panels[messageID]?.setText(partID, text) + revalidate() + repaint() + } + + fun appendDelta(messageID: String, partID: String, delta: String) { + panels[messageID]?.appendDelta(partID, delta) + revalidate() + repaint() + } + + fun removeMessage(messageID: String) { + val block = panels.remove(messageID) ?: return + inner.remove(block) + revalidate() + repaint() + } + + fun addError(msg: String) { + val label = JBLabel(msg).apply { + foreground = JBColor.RED + font = JBUI.Fonts.label() + border = JBUI.Borders.empty(4, 0) + alignmentX = LEFT_ALIGNMENT + } + inner.add(label, inner.componentCount - 1) + revalidate() + repaint() + } + + /** + * Show or hide the working status indicator at the bottom of the list. + * Pass null to hide, a string to show with animated spinner. + */ + fun setStatus(text: String?) { + if (text != null) { + statusLabel.text = text + status.isVisible = true + } else { + status.isVisible = false + } + revalidate() + repaint() + } + + fun clear() { + panels.clear() + inner.removeAll() + // Re-add status row (always last) + inner.add(status) + status.isVisible = false + revalidate() + repaint() + } +} + +/** + * A single message block — text content only, no role header. + * User messages get a thin top border as separator. + */ +private class MessageBlock(info: MessageDto) : JPanel() { + private val parts = LinkedHashMap() + + init { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + alignmentX = LEFT_ALIGNMENT + + border = if (info.role == "user") { + JBUI.Borders.compound( + MatteBorder(1, 0, 0, 0, JBColor.border()), + JBUI.Borders.empty(8, 0, 4, 0), + ) + } else { + JBUI.Borders.empty(4, 0) + } + } + + fun setText(partID: String, text: String) { + val area = parts.getOrPut(partID) { createArea().also { add(it) } } + area.text = text + revalidate() + } + + fun appendDelta(partID: String, delta: String) { + val area = parts.getOrPut(partID) { createArea().also { add(it) } } + area.append(delta) + revalidate() + } + + private fun createArea() = JTextArea().apply { + isEditable = false + lineWrap = true + wrapStyleWord = true + isOpaque = false + font = JBUI.Fonts.label() + foreground = UIUtil.getLabelForeground() + border = JBUI.Borders.empty() + alignmentX = LEFT_ALIGNMENT + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PromptPanel.kt new file mode 100644 index 00000000000..0bc46fc5a1b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PromptPanel.kt @@ -0,0 +1,132 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.plugin.KiloBundle +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.EditorTextField +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.Icon +import javax.swing.JButton +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants + +/** + * Prompt input panel with an IntelliJ editor text field and a bottom + * bar containing mode/model pickers and a send/stop button, all on + * the same row stretched to the same height. + * + * Layout: + * ``` + * ┌──────────────────────────────────┐ + * │ EditorTextField (3 lines) │ + * ├──────────────────────────────────┤ + * │ [Default ▾] [sonnet ▾] [▶] │ + * └──────────────────────────────────┘ + * ``` + */ +class PromptPanel( + private val project: Project, + private val onSend: (String) -> Unit, + private val onAbort: () -> Unit, +) : JPanel(BorderLayout()) { + + companion object { + private val SEND_ICON: Icon = IconLoader.getIcon("/icons/send.svg", PromptPanel::class.java) + private val STOP_ICON: Icon = IconLoader.getIcon("/icons/stop.svg", PromptPanel::class.java) + private const val EDITOR_LINES = 3 + } + + val mode = LabelPicker() + val model = LabelPicker() + + private val editor = EditorTextField(project, PlainTextFileType.INSTANCE).apply { + setPlaceholder(KiloBundle.message("prompt.placeholder")) + setShowPlaceholderWhenFocused(true) + setOneLineMode(false) + addSettingsProvider { ed -> + ed.settings.isUseSoftWraps = true + ed.settings.isAdditionalPageAtBottom = false + ed.scrollPane.horizontalScrollBarPolicy = + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.contentComponent.addKeyListener(object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if (e.keyCode == KeyEvent.VK_ENTER && !e.isShiftDown) { + e.consume() + submit() + } + } + }) + } + } + + private val button = JButton(SEND_ICON).apply { + isBorderPainted = false + isContentAreaFilled = false + isFocusPainted = false + toolTipText = KiloBundle.message("prompt.button.send") + isEnabled = false + maximumSize = Dimension(JBUI.scale(28), Short.MAX_VALUE.toInt()) + preferredSize = Dimension(JBUI.scale(28), JBUI.scale(24)) + addActionListener { if (busy) onAbort() else submit() } + } + + @Volatile + private var busy = false + + init { + border = JBUI.Borders.empty(4, 8, 4, 8) + + // Editor in center — constrain height to ~3 lines + val height = editor.font.size * EDITOR_LINES + JBUI.scale(16) + editor.preferredSize = Dimension(0, height) + editor.minimumSize = Dimension(0, height) + add(editor, BorderLayout.CENTER) + + // Bottom bar: pickers + glue + send button, all same row & height + val bar = JPanel().apply { + layout = BoxLayout(this, BoxLayout.X_AXIS) + isOpaque = false + border = JBUI.Borders.emptyTop(4) + } + bar.add(mode) + bar.add(model) + bar.add(Box.createHorizontalGlue()) + bar.add(button) + add(bar, BorderLayout.SOUTH) + } + + fun setReady(value: Boolean) { + button.isEnabled = value + } + + fun setBusy(value: Boolean) { + busy = value + button.icon = if (value) STOP_ICON else SEND_ICON + button.toolTipText = if (value) KiloBundle.message("prompt.button.stop") else KiloBundle.message("prompt.button.send") + } + + fun text(): String = editor.text.trim() + + fun clear() { + editor.text = "" + } + + fun focus() { + editor.requestFocusInWindow() + } + + private fun submit() { + if (busy) return + val txt = text() + if (txt.isNotEmpty()) { + onSend(txt) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt similarity index 54% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt index 75a8c6764fe..fc543dd03db 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloWelcomeUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt @@ -1,5 +1,8 @@ -package ai.kilocode.client +package ai.kilocode.client.session.ui +import ai.kilocode.client.session.model.SessionEvent +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelListener import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto @@ -8,16 +11,13 @@ import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.ProfileStatusDto import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable -import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.IconLoader import com.intellij.ui.AnimatedIcon import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch +import java.awt.Font import java.awt.GridBagConstraints import java.awt.GridBagLayout import javax.swing.Box @@ -29,15 +29,22 @@ import javax.swing.SwingConstants /** * Welcome panel showing app + workspace initialization progress. * + * Pure view — listens to [SessionModel] events and reads + * [SessionState][ai.kilocode.client.session.model.SessionState] for data. + * No coroutines, no service references. + * * Uses icon+label rows for each resource being loaded. Icons act as * status indicators: animated spinner for loading, green check for * success, red circle for error, grey circle for idle. */ -class KiloWelcomeUi( - private val app: KiloAppService, - private val workspace: KiloProjectService, - private val cs: CoroutineScope, -) : JPanel(GridBagLayout()), Disposable { +class StatusPanel( + parent: Disposable, + private val model: SessionModel, +) : JPanel(GridBagLayout()), SessionModelListener, Disposable { + + init { + Disposer.register(parent, this) + } // ------ status icons ------ @@ -50,7 +57,7 @@ class KiloWelcomeUi( // ------ header ------ private val logo = JBLabel( - IconLoader.getIcon("/icons/kilo-content.svg", KiloWelcomeUi::class.java), + IconLoader.getIcon("/icons/kilo-content.svg", StatusPanel::class.java), ).apply { alignmentX = CENTER_ALIGNMENT } @@ -64,30 +71,25 @@ class KiloWelcomeUi( // ------ app rows ------ - private val configRow = row("Config") - private val notifRow = row("Notifications") - private val profileRow = row("Profile") + private val configRow = row(KiloBundle.message("toolwindow.row.config")) + private val notifRow = row(KiloBundle.message("toolwindow.row.notifications")) + private val profileRow = row(KiloBundle.message("toolwindow.row.profile")) // ------ workspace rows ------ - private val providersRow = row("Providers") - private val agentsRow = row("Agents") - private val commandsRow = row("Commands") - private val skillsRow = row("Skills") + private val providersRow = row(KiloBundle.message("toolwindow.row.providers")) + private val agentsRow = row(KiloBundle.message("toolwindow.row.agents")) + private val commandsRow = row(KiloBundle.message("toolwindow.row.commands")) + private val skillsRow = row(KiloBundle.message("toolwindow.row.skills")) // ------ section headers ------ - private val appHeader = header("App") - private val wsHeader = header("Workspace") + private val appHeader = header(KiloBundle.message("toolwindow.section.app")) + private val wsHeader = header(KiloBundle.message("toolwindow.section.workspace")) - // Section panels group header + rows with left-aligned content. - // Each section is center-aligned as a block in the outer layout. private val appSection = section(appHeader, configRow, notifRow, profileRow) private val wsSection = section(wsHeader, providersRow, agentsRow, commandsRow, skillsRow) - private var appJob: Job? = null - private var wsJob: Job? = null - init { isOpaque = false @@ -105,30 +107,28 @@ class KiloWelcomeUi( add(wsSection) } - // GridBagLayout with default constraints centers the body - // both vertically and horizontally in the tool window. add(body, GridBagConstraints()) - // Initial state: everything idle resetAll() - - appJob = app.watch { state -> - edt { renderApp(state) } - } - - wsJob = cs.launch { - workspace.state.collect { state -> - edt { renderWorkspace(state) } - } - } - - app.connect() + model.addListener(this, this) } - override fun dispose() { - appJob?.cancel() - wsJob?.cancel() - cs.cancel() + override fun onEvent(event: SessionEvent) { + when (event) { + is SessionEvent.AppChanged -> { + renderApp(model.chat.app) + revalidate() + repaint() + } + + is SessionEvent.WorkspaceChanged -> { + renderWorkspace(model.chat.workspace) + revalidate() + repaint() + } + + else -> {} + } } // ------ rendering ------ @@ -148,61 +148,64 @@ class KiloWelcomeUi( KiloAppStatusDto.LOADING -> { val p = state.progress if (p != null) { - if (p.config) configRow.ok("Config") else configRow.loading() - if (p.notifications) notifRow.ok("Notifications") else notifRow.loading() + if (p.config) configRow.ok(KiloBundle.message("toolwindow.row.config")) else configRow.loading() + if (p.notifications) notifRow.ok(KiloBundle.message("toolwindow.row.notifications")) else notifRow.loading() renderProfile(p.profile) } } KiloAppStatusDto.READY -> { val p = state.progress if (p != null) { - configRow.ok("Config") - notifRow.ok("Notifications") + configRow.ok(KiloBundle.message("toolwindow.row.config")) + notifRow.ok(KiloBundle.message("toolwindow.row.notifications")) renderProfile(p.profile) } else { - configRow.ok("Config") - notifRow.ok("Notifications") - profileRow.ok("Logged in") + configRow.ok(KiloBundle.message("toolwindow.row.config")) + notifRow.ok(KiloBundle.message("toolwindow.row.notifications")) + profileRow.ok(KiloBundle.message("toolwindow.profile.loggedin")) } } KiloAppStatusDto.ERROR -> { val errors = state.errors.associate { it.resource to it } configRow.apply { - if ("config" in errors) error("Config: ${errors["config"]?.detail ?: "failed"}") - else ok("Config") + val detail = errors["config"]?.detail ?: KiloBundle.message("toolwindow.error.failed") + if ("config" in errors) error(KiloBundle.message("toolwindow.error.config", detail)) + else ok(KiloBundle.message("toolwindow.row.config")) } notifRow.apply { - if ("notifications" in errors) error("Notifications: ${errors["notifications"]?.detail ?: "failed"}") - else ok("Notifications") + val detail = errors["notifications"]?.detail ?: KiloBundle.message("toolwindow.error.failed") + if ("notifications" in errors) error(KiloBundle.message("toolwindow.error.notifications", detail)) + else ok(KiloBundle.message("toolwindow.row.notifications")) } profileRow.apply { - if ("profile" in errors) error("Profile: ${errors["profile"]?.detail ?: "failed"}") - else ok("Logged in") + val detail = errors["profile"]?.detail ?: KiloBundle.message("toolwindow.error.failed") + if ("profile" in errors) error(KiloBundle.message("toolwindow.error.profile", detail)) + else ok(KiloBundle.message("toolwindow.profile.loggedin")) } } } } private fun renderWorkspace(state: KiloWorkspaceStateDto) { - val appReady = app.state.value.status == KiloAppStatusDto.READY + val appReady = model.chat.app.status == KiloAppStatusDto.READY val visible = appReady || state.status != KiloWorkspaceStatusDto.PENDING wsSection.isVisible = visible if (!visible) return when (state.status) { KiloWorkspaceStatusDto.PENDING -> { - providersRow.idle("Providers") - agentsRow.idle("Agents") - commandsRow.idle("Commands") - skillsRow.idle("Skills") + providersRow.idle(KiloBundle.message("toolwindow.row.providers")) + agentsRow.idle(KiloBundle.message("toolwindow.row.agents")) + commandsRow.idle(KiloBundle.message("toolwindow.row.commands")) + skillsRow.idle(KiloBundle.message("toolwindow.row.skills")) } KiloWorkspaceStatusDto.LOADING -> { val p = state.progress if (p != null) { - if (p.providers) providersRow.ok("Providers") else providersRow.loading() - if (p.agents) agentsRow.ok("Agents") else agentsRow.loading() - if (p.commands) commandsRow.ok("Commands") else commandsRow.loading() - if (p.skills) skillsRow.ok("Skills") else skillsRow.loading() + if (p.providers) providersRow.ok(KiloBundle.message("toolwindow.row.providers")) else providersRow.loading() + if (p.agents) agentsRow.ok(KiloBundle.message("toolwindow.row.agents")) else agentsRow.loading() + if (p.commands) commandsRow.ok(KiloBundle.message("toolwindow.row.commands")) else commandsRow.loading() + if (p.skills) skillsRow.ok(KiloBundle.message("toolwindow.row.skills")) else skillsRow.loading() } else { providersRow.loading() agentsRow.loading() @@ -215,17 +218,17 @@ class KiloWelcomeUi( val ag = state.agents?.all?.size ?: 0 val cmd = state.commands.size val sk = state.skills.size - providersRow.ok("Providers ($prov)") - agentsRow.ok("Agents ($ag)") - commandsRow.ok("Commands ($cmd)") - skillsRow.ok("Skills ($sk)") + providersRow.ok(KiloBundle.message("toolwindow.row.providers.count", prov)) + agentsRow.ok(KiloBundle.message("toolwindow.row.agents.count", ag)) + commandsRow.ok(KiloBundle.message("toolwindow.row.commands.count", cmd)) + skillsRow.ok(KiloBundle.message("toolwindow.row.skills.count", sk)) } KiloWorkspaceStatusDto.ERROR -> { - val msg = state.error ?: "Unknown error" + val msg = state.error ?: KiloBundle.message("toolwindow.error.unknown") providersRow.error(msg) - agentsRow.idle("Agents") - commandsRow.idle("Commands") - skillsRow.idle("Skills") + agentsRow.idle(KiloBundle.message("toolwindow.row.agents")) + commandsRow.idle(KiloBundle.message("toolwindow.row.commands")) + skillsRow.idle(KiloBundle.message("toolwindow.row.skills")) } } } @@ -238,8 +241,9 @@ class KiloWelcomeUi( KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting") KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading") KiloAppStatusDto.READY -> { - val ver = app.version - if (ver != null) "Connected (CLI $ver)" else KiloBundle.message("toolwindow.status.connected") + val ver = model.chat.version + if (ver != null) KiloBundle.message("toolwindow.status.connected.version", ver) + else KiloBundle.message("toolwindow.status.connected") } KiloAppStatusDto.ERROR -> KiloBundle.message( "toolwindow.status.error", @@ -249,24 +253,20 @@ class KiloWelcomeUi( private fun renderProfile(profile: ProfileStatusDto) { when (profile) { - ProfileStatusDto.LOADED -> profileRow.ok("Logged in") - ProfileStatusDto.NOT_LOGGED_IN -> profileRow.warn("Not logged in") - ProfileStatusDto.PENDING -> profileRow.loading("Profile") + ProfileStatusDto.LOADED -> profileRow.ok(KiloBundle.message("toolwindow.profile.loggedin")) + ProfileStatusDto.NOT_LOGGED_IN -> profileRow.warn(KiloBundle.message("toolwindow.profile.notloggedin")) + ProfileStatusDto.PENDING -> profileRow.loading(KiloBundle.message("toolwindow.row.profile")) } } private fun resetAll() { - configRow.idle("Config") - notifRow.idle("Notifications") - profileRow.idle("Profile") - providersRow.idle("Providers") - agentsRow.idle("Agents") - commandsRow.idle("Commands") - skillsRow.idle("Skills") - } - - private fun edt(block: () -> Unit) { - ApplicationManager.getApplication().invokeLater(block) + configRow.idle(KiloBundle.message("toolwindow.row.config")) + notifRow.idle(KiloBundle.message("toolwindow.row.notifications")) + profileRow.idle(KiloBundle.message("toolwindow.row.profile")) + providersRow.idle(KiloBundle.message("toolwindow.row.providers")) + agentsRow.idle(KiloBundle.message("toolwindow.row.agents")) + commandsRow.idle(KiloBundle.message("toolwindow.row.commands")) + skillsRow.idle(KiloBundle.message("toolwindow.row.skills")) } // ------ row factory ------ @@ -275,15 +275,11 @@ class KiloWelcomeUi( private fun header(text: String): JBLabel = JBLabel(text).apply { alignmentX = LEFT_ALIGNMENT - font = JBUI.Fonts.label().deriveFont(JBUI.Fonts.label().style or java.awt.Font.BOLD) + font = JBUI.Fonts.label().deriveFont(JBUI.Fonts.label().style or Font.BOLD) foreground = UIUtil.getLabelForeground() border = JBUI.Borders.empty(0, 0, 4, 0) } - /** - * Groups a header and rows into a left-aligned block that - * is centered as a unit inside the outer BoxLayout. - */ private fun section(hdr: JBLabel, vararg rows: StatusRow): JPanel = JPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) isOpaque = false @@ -292,10 +288,6 @@ class KiloWelcomeUi( for (r in rows) add(r.label) } - /** - * A single status row: icon on the left, label on the right. - * Mutate via [ok], [loading], [error], [idle]. - */ inner class StatusRow(text: String, icon: Icon) { val label = JBLabel(text, icon, SwingConstants.LEFT).apply { font = JBUI.Fonts.label() @@ -335,4 +327,8 @@ class KiloWelcomeUi( label.foreground = UIUtil.getContextHelpForeground() } } + + override fun dispose() { + // Listener auto-removed by Disposer (registered in init via addListener) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/send.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/send.svg new file mode 100644 index 00000000000..41f740ad42f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/send.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/send_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/send_dark.svg new file mode 100644 index 00000000000..8e075e2d9b9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/send_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/stop.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/stop.svg new file mode 100644 index 00000000000..d405bfea86b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/stop.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/stop_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/stop_dark.svg new file mode 100644 index 00000000000..56084d26b4c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/stop_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index abffce3b604..443c43d1365 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -2,18 +2,51 @@ toolwindow.status.disconnected=Status: Disconnected toolwindow.status.connecting=Status: Connecting... toolwindow.status.loading=Status: Loading... toolwindow.status.connected=Status: Connected +toolwindow.status.connected.version=Connected (CLI {0}) toolwindow.status.error=Status: Error - {0} toolwindow.error.unknown=Unknown error - -toolwindow.status.connected.short=Connected -toolwindow.status.connecting.short=Connecting\u2026 -toolwindow.status.loading.short=Loading\u2026 -toolwindow.status.disconnected.short=Disconnected -toolwindow.status.error.short=Error +toolwindow.error.failed=failed toolwindow.section.app=App toolwindow.section.workspace=Workspace -toolwindow.workspace.pending=Waiting for connection\u2026 + +toolwindow.row.config=Config +toolwindow.row.notifications=Notifications +toolwindow.row.profile=Profile +toolwindow.row.providers=Providers +toolwindow.row.agents=Agents +toolwindow.row.commands=Commands +toolwindow.row.skills=Skills + +toolwindow.row.providers.count=Providers ({0}) +toolwindow.row.agents.count=Agents ({0}) +toolwindow.row.commands.count=Commands ({0}) +toolwindow.row.skills.count=Skills ({0}) + +toolwindow.profile.loggedin=Logged in +toolwindow.profile.notloggedin=Not logged in + +toolwindow.error.config=Config: {0} +toolwindow.error.notifications=Notifications: {0} +toolwindow.error.profile=Profile: {0} + +session.status.considering=Considering next steps\u2026 +session.status.thinking=Thinking\u2026 +session.status.writing=Writing response\u2026 +session.status.delegating=Delegating work\u2026 +session.status.planning=Planning\u2026 +session.status.gathering=Gathering context\u2026 +session.status.searching.codebase=Searching codebase\u2026 +session.status.searching.web=Searching web\u2026 +session.status.editing=Making edits\u2026 +session.status.commands=Running commands\u2026 + +session.error.prompt=Prompt failed +session.error.unknown=Unknown error + +prompt.placeholder=Type a message\u2026 +prompt.button.send=Send +prompt.button.stop=Stop action.Kilo.Settings.text=Settings action.Kilo.Settings.description=Kilo Code settings diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt new file mode 100644 index 00000000000..d22150f9040 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/AppWatchingTest.kt @@ -0,0 +1,19 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto + +class AppWatchingTest : SessionModelTestBase() { + + fun `test app state change fires AppChanged`() { + val m = model() + val events = collect(m) + flush() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + flush() + + assertTrue(events.any { it is SessionEvent.AppChanged }) + assertEquals(KiloAppStatusDto.READY, m.chat.app.status) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt new file mode 100644 index 00000000000..370aad40e41 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ConfigSelectionTest.kt @@ -0,0 +1,41 @@ +package ai.kilocode.client.session.model + +class ConfigSelectionTest : SessionModelTestBase() { + + fun `test selectModel updates ChatModel and calls updateConfig`() { + val m = model() + collect(m) + flush() + + edt { m.selectModel("kilo", "gpt-5") } + flush() + + assertEquals("kilo/gpt-5", m.chat.model) + assertEquals(1, rpc.configs.size) + assertEquals("kilo/gpt-5", rpc.configs[0].second.model) + } + + fun `test selectAgent updates ChatModel and calls updateConfig`() { + val m = model() + collect(m) + flush() + + edt { m.selectAgent("plan") } + flush() + + assertEquals("plan", m.chat.agent) + assertEquals(1, rpc.configs.size) + assertEquals("plan", rpc.configs[0].second.agent) + } + + fun `test selectModel fires WorkspaceReady event`() { + val m = model() + val events = collect(m) + flush() + + edt { m.selectModel("kilo", "gpt-5") } + flush() + + assertTrue(events.any { it is SessionEvent.WorkspaceReady }) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt new file mode 100644 index 00000000000..84a9b277996 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/HistoryLoadingTest.kt @@ -0,0 +1,31 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.MessageWithPartsDto + +class HistoryLoadingTest : SessionModelTestBase() { + + fun `test existing session loads history on init`() { + val m = msg("msg1", "ses_test", "user") + val p = part("prt1", "ses_test", "msg1", "text", text = "hello") + rpc.history.add(MessageWithPartsDto(m, listOf(p))) + + val model = model("ses_test") + val events = collect(model) + flush() + + assertTrue(events.any { it is SessionEvent.HistoryLoaded }) + assertNotNull(model.chat.message("msg1")) + assertEquals("hello", model.chat.part("msg1", "prt1")?.text?.toString()) + } + + fun `test non-empty history shows messages view`() { + rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) + + val model = model("ses_test") + val events = collect(model) + flush() + + assertTrue(events.any { it is SessionEvent.ViewChanged && it.show }) + assertTrue(model.chat.showMessages) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt new file mode 100644 index 00000000000..88187e7ffc3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ListenerLifecycleTest.kt @@ -0,0 +1,60 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.SessionStatusDto +import com.intellij.openapi.util.Disposer + +class ListenerLifecycleTest : SessionModelTestBase() { + + fun `test listener removed on parent dispose`() { + val m = model() + val disposable = Disposer.newDisposable("listener-parent") + Disposer.register(parent, disposable) + + val events = mutableListOf() + m.addListener(disposable) { events.add(it) } + + edt { m.prompt("before") } + flush() + val before = events.size + + Disposer.dispose(disposable) + + edt { m.prompt("after") } + flush() + + assertEquals(before, events.size) + } + + fun `test all listeners notified`() { + val m = model() + val events1 = mutableListOf() + val events2 = mutableListOf() + val d1 = Disposer.newDisposable("l1") + val d2 = Disposer.newDisposable("l2") + Disposer.register(parent, d1) + Disposer.register(parent, d2) + + m.addListener(d1) { events1.add(it) } + m.addListener(d2) { events2.add(it) } + + edt { m.prompt("go") } + flush() + + assertTrue(events1.isNotEmpty()) + assertTrue(events2.isNotEmpty()) + assertEquals(events1.map { it::class }, events2.map { it::class }) + } + + fun `test session status busy fires BusyChanged`() { + val m = model() + val events = collect(m) + + edt { m.prompt("go") } + flush() + + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy", null)) + flush() + + assertTrue(events.any { it is SessionEvent.BusyChanged && it.busy }) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt new file mode 100644 index 00000000000..d155dc83f32 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/MessageListTest.kt @@ -0,0 +1,55 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.ChatEventDto + +class MessageListTest : SessionModelTestBase() { + + fun `test MessageUpdated adds message to ChatModel`() { + val (m, events) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + flush() + + assertTrue(events.any { it is SessionEvent.MessageAdded && it.id == "msg1" }) + assertNotNull(m.chat.message("msg1")) + } + + fun `test PartUpdated text fires PartUpdated event`() { + val (m, events) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + flush() + + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello"))) + flush() + + assertTrue(events.any { it is SessionEvent.PartUpdated && it.messageId == "msg1" && it.partId == "prt1" }) + } + + fun `test PartDelta appends text to ChatModel`() { + val (m, _) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + flush() + + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "hello ")) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world")) + flush() + + val p = m.chat.part("msg1", "prt1") + assertNotNull(p) + assertEquals("hello world", p!!.text.toString()) + } + + fun `test MessageRemoved removes from ChatModel`() { + val (m, _) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "user"))) + flush() + assertNotNull(m.chat.message("msg1")) + + emit(ChatEventDto.MessageRemoved("ses_test", "msg1")) + flush() + assertNull(m.chat.message("msg1")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt new file mode 100644 index 00000000000..d1a02f01798 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionCreationTest.kt @@ -0,0 +1,43 @@ +package ai.kilocode.client.session.model + +class SessionCreationTest : SessionModelTestBase() { + + fun `test prompt creates session on first call`() { + val m = model() + val events = collect(m) + + edt { m.prompt("hello") } + flush() + + assertEquals(1, rpc.creates) + assertEquals(1, rpc.prompts.size) + assertEquals("ses_test", rpc.prompts[0].first) + assertTrue(events.any { it is SessionEvent.ViewChanged && it.show }) + } + + fun `test prompt reuses existing session`() { + val m = model() + + edt { m.prompt("first") } + flush() + edt { m.prompt("second") } + flush() + + assertEquals(1, rpc.creates) + assertEquals(2, rpc.prompts.size) + assertEquals("ses_test", rpc.prompts[1].first) + } + + fun `test prompt with existing ID skips creation`() { + val m = model("existing") + collect(m) + flush() + + edt { m.prompt("hello") } + flush() + + assertEquals(0, rpc.creates) + assertEquals(1, rpc.prompts.size) + assertEquals("existing", rpc.prompts[0].first) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTestBase.kt new file mode 100644 index 00000000000..d7b4ea1b2b3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTestBase.kt @@ -0,0 +1,165 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.rpc.dto.AgentDto +import ai.kilocode.rpc.dto.AgentsDto +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.ProviderDto +import ai.kilocode.rpc.dto.ProvidersDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking + +/** + * Base class for [SessionModel] tests. + * + * Provides real IntelliJ Application/EDT/Disposer via [BasePlatformTestCase], + * real frontend services wired to fake RPC backends, and shared helpers. + */ +abstract class SessionModelTestBase : BasePlatformTestCase() { + + protected lateinit var rpc: FakeSessionRpcApi + protected lateinit var appRpc: FakeAppRpcApi + protected lateinit var projectRpc: FakeWorkspaceRpcApi + + protected lateinit var sessions: KiloSessionService + protected lateinit var app: KiloAppService + protected lateinit var workspaces: KiloWorkspaceService + protected lateinit var workspace: Workspace + + protected lateinit var scope: CoroutineScope + protected lateinit var parent: Disposable + + override fun setUp() { + super.setUp() + rpc = FakeSessionRpcApi() + appRpc = FakeAppRpcApi() + projectRpc = FakeWorkspaceRpcApi() + + scope = CoroutineScope(SupervisorJob()) + parent = Disposer.newDisposable("test") + + sessions = KiloSessionService(project, scope, rpc) + app = KiloAppService(scope, appRpc) + workspaces = KiloWorkspaceService(scope, projectRpc) + workspace = workspaces.workspace("/test") + } + + override fun tearDown() { + try { + Disposer.dispose(parent) + scope.cancel() + } finally { + super.tearDown() + } + } + + // ------ Model creation ------ + + protected fun model(id: String? = null) = + SessionModel(parent, id, sessions, workspace, app, scope) + + // ------ Event collection ------ + + /** Attach a listener that collects events and asserts EDT. */ + protected fun collect(m: SessionModel): MutableList { + val events = mutableListOf() + val disposable = Disposer.newDisposable("listener") + Disposer.register(parent, disposable) + m.addListener(disposable) { event -> + assertTrue("Listener must be called on EDT", ApplicationManager.getApplication().isDispatchThread) + events.add(event) + } + return events + } + + // ------ EDT + coroutine helpers ------ + + /** Let coroutines settle, then drain all pending EDT events. */ + protected fun flush() = runBlocking { + repeat(5) { + delay(100) + edt { UIUtil.dispatchAllInvocationEvents() } + } + } + + protected fun edt(block: () -> Unit) { + ApplicationManager.getApplication().invokeAndWait(block) + } + + /** Emit a chat event into the fake RPC flow. */ + protected fun emit(event: ChatEventDto) = runBlocking { + rpc.events.emit(event) + } + + /** Create a model, attach listener, send initial prompt, and flush. */ + protected fun prompted(): Pair> { + val m = model() + val events = collect(m) + edt { m.prompt("go") } + flush() + return m to events + } + + // ------ DTO factories ------ + + protected fun msg(id: String, sid: String, role: String) = MessageDto( + id = id, + sessionID = sid, + role = role, + time = MessageTimeDto(created = 0.0), + ) + + protected fun part( + id: String, + sid: String, + mid: String, + type: String, + text: String? = null, + tool: String? = null, + ) = PartDto( + id = id, + sessionID = sid, + messageID = mid, + type = type, + text = text, + tool = tool, + ) + + protected fun workspaceReady( + agents: List = listOf(AgentDto(name = "code", displayName = "Code", mode = "code")), + default: String = "code", + providers: List = listOf( + ProviderDto( + id = "kilo", + name = "Kilo", + models = mapOf("gpt-5" to ModelDto(id = "gpt-5", name = "GPT-5")), + ), + ), + connected: List = listOf("kilo"), + defaults: Map = mapOf("kilo" to "gpt-5"), + ) = KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.READY, + agents = AgentsDto(agents = agents, all = agents, default = default), + providers = ProvidersDto(providers = providers, connected = connected, defaults = defaults), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt new file mode 100644 index 00000000000..08d5845348b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/StatusComputationTest.kt @@ -0,0 +1,44 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.rpc.dto.ChatEventDto + +class StatusComputationTest : SessionModelTestBase() { + + fun `test status shows tool-specific text`() { + val (_, events) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + flush() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + flush() + + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash"))) + flush() + + val status = events.filterIsInstance() + .lastOrNull { it.text != null && it.text != KiloBundle.message("session.status.considering") } + assertNotNull(status) + assertEquals(KiloBundle.message("session.status.commands"), status!!.text) + } + + fun `test PartUpdated after TurnClose does not fire StatusChanged`() { + val (_, events) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + flush() + emit(ChatEventDto.TurnOpen("ses_test")) + flush() + emit(ChatEventDto.TurnClose("ses_test", "completed")) + flush() + + val before = events.filterIsInstance().size + + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "late"))) + flush() + + val after = events.filterIsInstance().size + assertEquals(before, after) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt new file mode 100644 index 00000000000..dff16b01c58 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnLifecycleTest.kt @@ -0,0 +1,53 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.MessageErrorDto + +class TurnLifecycleTest : SessionModelTestBase() { + + fun `test TurnOpen fires BusyChanged true`() { + val (_, events) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + flush() + + assertTrue(events.any { it is SessionEvent.BusyChanged && it.busy }) + assertTrue(events.any { it is SessionEvent.StatusChanged && it.text == KiloBundle.message("session.status.considering") }) + } + + fun `test TurnClose fires BusyChanged false and clears status`() { + val (_, events) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + flush() + emit(ChatEventDto.TurnClose("ses_test", "completed")) + flush() + + val last = events.filterIsInstance().last() + assertFalse(last.busy) + val status = events.filterIsInstance().last() + assertNull(status.text) + } + + fun `test Error fires Error event with message`() { + val (_, events) = prompted() + + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Bad Request"))) + flush() + + val err = events.filterIsInstance().firstOrNull() + assertNotNull(err) + assertEquals("Bad Request", err!!.message) + } + + fun `test Error with null message falls back to type`() { + val (_, events) = prompted() + + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = null))) + flush() + + val err = events.filterIsInstance().first() + assertEquals("timeout", err.message) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt new file mode 100644 index 00000000000..996140dec5f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/ViewSwitchingTest.kt @@ -0,0 +1,26 @@ +package ai.kilocode.client.session.model + +class ViewSwitchingTest : SessionModelTestBase() { + + fun `test first prompt shows messages view`() { + val m = model() + val events = collect(m) + + edt { m.prompt("hello") } + flush() + + assertTrue(events.any { it is SessionEvent.ViewChanged && it.show }) + } + + fun `test ViewChanged not fired twice`() { + val m = model() + val events = collect(m) + + edt { m.prompt("first") } + flush() + edt { m.prompt("second") } + flush() + + assertEquals(1, events.count { it is SessionEvent.ViewChanged && it.show }) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt new file mode 100644 index 00000000000..afb1f66c54f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/WorkspaceWatchingTest.kt @@ -0,0 +1,32 @@ +package ai.kilocode.client.session.model + +class WorkspaceWatchingTest : SessionModelTestBase() { + + fun `test workspace ready populates agents and models`() { + val m = model() + val events = collect(m) + flush() + + projectRpc.state.value = workspaceReady() + flush() + + assertEquals(1, m.chat.agents.size) + assertEquals("code", m.chat.agents[0].name) + assertEquals(1, m.chat.models.size) + assertEquals("gpt-5", m.chat.models[0].id) + assertTrue(m.chat.ready) + assertTrue(events.any { it is SessionEvent.WorkspaceReady }) + } + + fun `test workspace ready sets default agent and model`() { + val m = model() + collect(m) + flush() + + projectRpc.state.value = workspaceReady() + flush() + + assertEquals("code", m.chat.agent) + assertEquals("gpt-5", m.chat.model) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/EdtAssertions.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/EdtAssertions.kt new file mode 100644 index 00000000000..17c1dee354d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/EdtAssertions.kt @@ -0,0 +1,15 @@ +package ai.kilocode.client.testing + +import com.intellij.openapi.application.ApplicationManager + +/** + * Assert that the current thread is NOT the EDT. + * Used in fake RPC implementations to verify that RPC calls + * are never made from the dispatch thread. + */ +fun assertNotEdt(method: String) { + val app = ApplicationManager.getApplication() ?: return + if (app.isDispatchThread) { + throw AssertionError("RPC method '$method' must not be called on the EDT") + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt new file mode 100644 index 00000000000..0919fcf3cbd --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -0,0 +1,47 @@ +package ai.kilocode.client.testing + +import ai.kilocode.rpc.KiloAppRpcApi +import ai.kilocode.rpc.dto.HealthDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Fake [KiloAppRpcApi] for testing. + * + * Push state changes via [state]. Health check returns [health]. + * + * Every `suspend` method asserts it is NOT called on the EDT. + */ +class FakeAppRpcApi : KiloAppRpcApi { + + val state = MutableStateFlow(KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)) + var health = HealthDto(healthy = true, version = "1.0.0") + + var connected = false + private set + + override suspend fun connect() { + assertNotEdt("connect") + connected = true + } + + override suspend fun state(): Flow { + assertNotEdt("state") + return state + } + + override suspend fun health(): HealthDto { + assertNotEdt("health") + return health + } + + override suspend fun restart() { + assertNotEdt("restart") + } + + override suspend fun reinstall() { + assertNotEdt("reinstall") + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt new file mode 100644 index 00000000000..85b8c255488 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -0,0 +1,114 @@ +package ai.kilocode.client.testing + +import ai.kilocode.rpc.KiloSessionRpcApi +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionListDto +import ai.kilocode.rpc.dto.SessionStatusDto +import ai.kilocode.rpc.dto.SessionTimeDto +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Fake [KiloSessionRpcApi] for testing. + * + * Configurable return values and call tracking. Push events + * via [events] and statuses via [statuses]. + * + * Every `suspend` method asserts it is NOT called on the EDT — + * RPC calls must happen on background threads. + */ +class FakeSessionRpcApi : KiloSessionRpcApi { + + /** The session returned by [create] and [get]. */ + var session = SessionDto( + id = "ses_test", + projectID = "proj_test", + directory = "/test", + title = "Test Session", + version = "1", + time = SessionTimeDto(created = 0.0, updated = 0.0), + ) + + /** Message history returned by [messages]. */ + val history = mutableListOf() + + /** Push chat events here; tests collect from [events]. */ + val events = MutableSharedFlow(extraBufferCapacity = 64, replay = 64) + + /** Push status updates here. */ + val statuses = MutableStateFlow>(emptyMap()) + + // --- Call tracking --- + + val prompts = mutableListOf>() + val aborts = mutableListOf>() + val configs = mutableListOf>() + var creates = 0 + private set + + // --- Implementation --- + + override suspend fun create(directory: String): SessionDto { + assertNotEdt("create") + creates++ + return session + } + + override suspend fun list(directory: String): SessionListDto { + assertNotEdt("list") + return SessionListDto(emptyList(), emptyMap()) + } + + override suspend fun get(id: String, directory: String): SessionDto { + assertNotEdt("get") + return session + } + + override suspend fun delete(id: String, directory: String) { + assertNotEdt("delete") + } + + override suspend fun statuses(): Flow> { + assertNotEdt("statuses") + return statuses + } + + override suspend fun setDirectory(id: String, directory: String) { + assertNotEdt("setDirectory") + } + + override suspend fun getDirectory(id: String, fallback: String): String { + assertNotEdt("getDirectory") + return fallback + } + + override suspend fun prompt(id: String, directory: String, prompt: PromptDto) { + assertNotEdt("prompt") + prompts.add(Triple(id, directory, prompt)) + } + + override suspend fun abort(id: String, directory: String) { + assertNotEdt("abort") + aborts.add(id to directory) + } + + override suspend fun messages(id: String, directory: String): List { + assertNotEdt("messages") + return history.toList() + } + + override suspend fun events(id: String, directory: String): Flow { + assertNotEdt("events") + return events + } + + override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) { + assertNotEdt("updateConfig") + configs.add(directory to config) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt new file mode 100644 index 00000000000..a85a1078058 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -0,0 +1,35 @@ +package ai.kilocode.client.testing + +import ai.kilocode.rpc.KiloWorkspaceRpcApi +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Fake [KiloWorkspaceRpcApi] for testing. + * + * Push workspace state changes via [state]. + * Directory resolution returns [directory]. + * + * Every `suspend` method asserts it is NOT called on the EDT. + */ +class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { + + var directory = "/test" + val state = MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)) + + override suspend fun resolveProjectDirectory(hint: String): String { + assertNotEdt("resolveProjectDirectory") + return directory + } + + override suspend fun state(directory: String): Flow { + assertNotEdt("state") + return state + } + + override suspend fun reload(directory: String) { + assertNotEdt("reload") + } +} diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 964f07922a7..9d81facbccf 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] intellij-platform = "2025.3" -intellij-gradle-plugin = "2.10.5" -intellij-rpc-plugin = "2.1.20-0.1" -kotlin-jvm-plugin = "2.1.20" -kotlin-serialization-plugin = "2.1.20" -kotlin-serialization = "1.8.1" +intellij-gradle-plugin = "2.14.0" +intellij-rpc-plugin = "2.3.20-0.1" +kotlin-jvm-plugin = "2.3.20" +kotlin-serialization-plugin = "2.3.20" +kotlin-serialization = "1.11.0" okhttp = "4.12.0" openapi-generator = "7.21.0" detekt = "1.23.8" diff --git a/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.jar b/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.jar index 249e5832f09..d997cfc60f4 100644 Binary files a/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.jar and b/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.properties b/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.properties index f50f69e3279..c61a118f7dd 100644 --- a/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.properties +++ b/packages/kilo-jetbrains/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/packages/kilo-jetbrains/gradlew b/packages/kilo-jetbrains/gradlew index 1b6c787337f..739907dfd15 100755 --- a/packages/kilo-jetbrains/gradlew +++ b/packages/kilo-jetbrains/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +82,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -133,22 +132,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -165,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -193,18 +198,27 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/packages/kilo-jetbrains/gradlew.bat b/packages/kilo-jetbrains/gradlew.bat index 107acd32c4e..c4bdd3ab8e3 100644 --- a/packages/kilo-jetbrains/gradlew.bat +++ b/packages/kilo-jetbrains/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,33 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index accadf58fc2..a482176d674 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -1,5 +1,9 @@ package ai.kilocode.rpc +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto import ai.kilocode.rpc.dto.SessionStatusDto @@ -45,4 +49,21 @@ interface KiloSessionRpcApi : RemoteApi { /** Get the effective directory for a session (worktree or fallback). */ suspend fun getDirectory(id: String, fallback: String): String + + // ------ chat ------ + + /** Send a prompt to a session (fire-and-forget). */ + suspend fun prompt(id: String, directory: String, prompt: PromptDto) + + /** Abort ongoing processing for a session. */ + suspend fun abort(id: String, directory: String) + + /** Load message history for a session. */ + suspend fun messages(id: String, directory: String): List + + /** Subscribe to streaming chat events for a specific session. */ + suspend fun events(id: String, directory: String): Flow + + /** Update config (model, agent/mode, temperature). */ + suspend fun updateConfig(directory: String, config: ConfigUpdateDto) } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt similarity index 61% rename from packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt rename to packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index b75a96c5559..f0f84295067 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -15,13 +15,22 @@ import kotlinx.coroutines.flow.Flow * via the workspace manager. */ @Rpc -interface KiloProjectRpcApi : RemoteApi { +interface KiloWorkspaceRpcApi : RemoteApi { companion object { - suspend fun getInstance(): KiloProjectRpcApi { - return RemoteApiProviderService.resolve(remoteApiDescriptor()) + suspend fun getInstance(): KiloWorkspaceRpcApi { + return RemoteApiProviderService.resolve(remoteApiDescriptor()) } } + /** + * Resolve the real project directory as seen by the backend. + * + * In split mode, the frontend's [Project.getBasePath] returns a + * synthetic sandbox path. This method returns the backend's actual + * project directory so the frontend can use it for CLI server calls. + */ + suspend fun resolveProjectDirectory(hint: String): String + /** Observe workspace state loading progress. */ suspend fun state(directory: String): Flow diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt new file mode 100644 index 00000000000..06ead59e46d --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -0,0 +1,144 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// --- Messages --- + +@Serializable +data class MessageDto( + val id: String, + val sessionID: String, + val role: String, + val time: MessageTimeDto, + val agent: String? = null, + val providerID: String? = null, + val modelID: String? = null, + val parentID: String? = null, + val cost: Double? = null, + val tokens: TokensDto? = null, + val error: MessageErrorDto? = null, +) + +@Serializable +data class MessageTimeDto( + val created: Double, + val completed: Double? = null, +) + +@Serializable +data class TokensDto( + val input: Long, + val output: Long, + val reasoning: Long, + val cacheRead: Long, + val cacheWrite: Long, +) + +@Serializable +data class MessageErrorDto( + val type: String, + val message: String? = null, +) + +@Serializable +data class MessageWithPartsDto( + val info: MessageDto, + val parts: List, +) + +// --- Parts (simplified for basic chat) --- + +@Serializable +data class PartDto( + val id: String, + val sessionID: String, + val messageID: String, + val type: String, + val text: String? = null, + val tool: String? = null, + val state: String? = null, + val title: String? = null, +) + +// --- Prompt Input --- + +@Serializable +data class PromptDto( + val parts: List, + val providerID: String? = null, + val modelID: String? = null, + val agent: String? = null, +) + +@Serializable +data class PromptPartDto( + val type: String, + val text: String, +) + +// --- Streaming Events --- + +@Serializable +sealed class ChatEventDto { + + @Serializable + @SerialName("message.updated") + data class MessageUpdated( + val sessionID: String, + val info: MessageDto, + ) : ChatEventDto() + + @Serializable + @SerialName("part.updated") + data class PartUpdated( + val sessionID: String, + val part: PartDto, + ) : ChatEventDto() + + @Serializable + @SerialName("part.delta") + data class PartDelta( + val sessionID: String, + val messageID: String, + val partID: String, + val field: String, + val delta: String, + ) : ChatEventDto() + + @Serializable + @SerialName("turn.open") + data class TurnOpen( + val sessionID: String, + ) : ChatEventDto() + + @Serializable + @SerialName("turn.close") + data class TurnClose( + val sessionID: String, + val reason: String, + ) : ChatEventDto() + + @Serializable + @SerialName("error") + data class Error( + val sessionID: String?, + val error: MessageErrorDto? = null, + ) : ChatEventDto() + + @Serializable + @SerialName("message.removed") + data class MessageRemoved( + val sessionID: String, + val messageID: String, + ) : ChatEventDto() +} + +// --- Config Update --- + +@Serializable +data class ConfigUpdateDto( + val model: String? = null, + val agent: String? = null, + val temperature: Double? = null, +) diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 66f7cc6b1f1..9af56f60aaa 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.2.10", + "version": "7.2.11", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 1ddb0b414bd..febf85a3f16 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.10", + "version": "7.2.11", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 0bd5c666168..aa30400b5d2 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -848,7 +848,7 @@ export function UserMessageDisplay(props: { - + diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 8ee78c56c42..3e07b7492f4 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,47 @@ # kilo-code +## 7.2.11 + +### Minor Changes + +- [#8894](https://github.com/Kilo-Org/kilocode/pull/8894) [`9fa90ee`](https://github.com/Kilo-Org/kilocode/commit/9fa90ee6389a608242a41da4ba6b8d8ce2f35f7d) - Add @terminal context mention support to the chat input. Type @terminal to include your active VS Code terminal output as context, with output safety limits (500 lines / 50K chars) and truncation. Works in both the sidebar chat and Agent Manager. + +- [#9034](https://github.com/Kilo-Org/kilocode/pull/9034) [`71852f2`](https://github.com/Kilo-Org/kilocode/commit/71852f2466afe955ff5aa12f7c7544cd7622551a) - Support writing a heap snapshot for the bundled CLI from the VS Code Command Palette. + +### Patch Changes + +- [#9036](https://github.com/Kilo-Org/kilocode/pull/9036) [`fd85a15`](https://github.com/Kilo-Org/kilocode/commit/fd85a15091553b6d9bcc14648f3b4cf865bdfbc5) - Clear queued follow-up prompts when aborting a running task. + +- [#9030](https://github.com/Kilo-Org/kilocode/pull/9030) [`e83d562`](https://github.com/Kilo-Org/kilocode/commit/e83d562a60ecd0fe9132faaa40ed38ee8979d42d) - Preserve cached Agent Manager git stats when reopening collapsed sections. + +- [#8898](https://github.com/Kilo-Org/kilocode/pull/8898) [`4a69a3e`](https://github.com/Kilo-Org/kilocode/commit/4a69a3e0d11a041827c1c68e1a47f84ed0f4c893) - Fixed default model falling back to the free model after login or org switch by invalidating cached provider state when auth changes. + +- [#9029](https://github.com/Kilo-Org/kilocode/pull/9029) [`e1da99c`](https://github.com/Kilo-Org/kilocode/commit/e1da99c4070eeae46d1469c919c3552ed2fe6015) - Fix queued-state detection so prompts sent after a completed response are treated as active instead of queued. + +- [#8703](https://github.com/Kilo-Org/kilocode/pull/8703) [`e67ed3f`](https://github.com/Kilo-Org/kilocode/commit/e67ed3f17a521f1b7a2726fb2ec74999d2ee5313) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Reduce git process load via visibility-aware polling and resolution caching in GitStatsPoller + +- [#9032](https://github.com/Kilo-Org/kilocode/pull/9032) [`38c746d`](https://github.com/Kilo-Org/kilocode/commit/38c746ddda7d91c0ff34fb7a75c6d64f53f378be) - Keep local review follow-up questions visible after review output so prompt input is not blocked by an invisible pending question. + +- [`4937759`](https://github.com/Kilo-Org/kilocode/commit/4937759bf46737a9300d4effedd627676ab4ca68) - Merged upstream opencode changes from v1.3.10: + - Subagent tool calls stay clickable while pending + - Improved storage migration reliability + - Better muted text contrast in Catppuccin themes + +- [`4937759`](https://github.com/Kilo-Org/kilocode/commit/4937759bf46737a9300d4effedd627676ab4ca68) - Merged upstream opencode changes from v1.3.6: + - Fixed token usage double-counting for Anthropic and Amazon Bedrock providers + - Fixed variant dialog search filtering + +- [`4937759`](https://github.com/Kilo-Org/kilocode/commit/4937759bf46737a9300d4effedd627676ab4ca68) - Merged upstream opencode changes from v1.3.7: + - Added first-class PowerShell support on Windows + - Plugin installs now preserve JSONC comments in configuration files + - Improved variant modal behavior to be less intrusive + +- [#9031](https://github.com/Kilo-Org/kilocode/pull/9031) [`d73e848`](https://github.com/Kilo-Org/kilocode/commit/d73e848cf13da6783594c378b325224282ebe5bd) - Restore localized tooltip text for message revert buttons + +- [`4937759`](https://github.com/Kilo-Org/kilocode/commit/4937759bf46737a9300d4effedd627676ab4ca68) - Make subsession costs in TaskHeader tooltip more readable with many subsessions + +- [#9025](https://github.com/Kilo-Org/kilocode/pull/9025) [`7dc526a`](https://github.com/Kilo-Org/kilocode/commit/7dc526a6c66b1bf1541668d2bde6c2d7980fb994) - Restore spacing between tool output and queued user messages in the VS Code chat. + ## 7.2.1 - Preserve write tool alongside apply_patch for GPT-5 models (@jacksonkasi1) diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index b7b8d49e786..53b83951bc4 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.2.10", + "version": "7.2.11", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -326,6 +326,11 @@ "command": "kilo-code.new.generateTerminalCommand", "title": "Generate Terminal Command", "category": "Kilo Code" + }, + { + "command": "kilo-code.new.takeHeapSnapshot", + "title": "Take Heap Snapshot", + "category": "Kilo Code" } ], "submenus": [ diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 63d56523321..a614613d05d 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -49,6 +49,7 @@ import { getTerminalContents } from "./services/terminal/context" import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session" import { childID } from "./kilo-provider/task-session" import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network" +import { abortSession, parseQueued } from "./kilo-provider/abort" import { retryable, backoff, MAX_RETRIES } from "./util/retry" import { hasGit } from "./kilo-provider/git-status" // legacy-migration start @@ -601,7 +602,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } case "abort": this.cancelRetry(message.sessionID ?? "") - await this.handleAbort(message.sessionID) + await this.handleAbort(message.sessionID, parseQueued(message.queuedMessageIDs)) break case "revertSession": this.handleRevertSession(message.sessionID, message.messageID).catch((e) => @@ -2558,10 +2559,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - /** - * Handle abort request from the webview. - */ - private async handleAbort(sessionID?: string): Promise { + private async handleAbort(sessionID?: string, queuedMessageIDs: string[] = []): Promise { if (!this.client) { return } @@ -2572,8 +2570,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } try { - const workspaceDir = this.getWorkspaceDirectory(targetSessionID) - await this.client.session.abort({ sessionID: targetSessionID, directory: workspaceDir }, { throwOnError: true }) + await abortSession({ + client: this.client, + sessionID: targetSessionID, + dir: this.getWorkspaceDirectory(targetSessionID), + queuedMessageIDs, + }) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to abort session:", error) } diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 2571e7fa2a0..1777c8c8e93 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -8,7 +8,7 @@ import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" import { WorktreeStateManager } from "./WorktreeStateManager" import { handleSection } from "./section-handler" import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch" -import { GitStatsPoller, type WorktreePresenceResult } from "./GitStatsPoller" +import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller" import { PRStatusBridge } from "./pr-status-bridge" import { GitOps } from "./GitOps" import { versionedName } from "./branch-name" @@ -59,8 +59,8 @@ export class AgentManagerProvider implements Disposable { private gitOps: GitOps private diffs: WorktreeDiffController private staleWorktreeIds = new Set() - private cachedWorktreeStats: AgentManagerOutMessage | undefined - private cachedLocalStats: AgentManagerOutMessage | undefined + private cachedWorktreeStats: { type: "agentManager.worktreeStats"; stats: WorktreeStats[] } | undefined + private cachedLocalStats: { type: "agentManager.localStats"; stats: LocalStats } | undefined /** Session ID most recently loaded via a `loadMessages` message from the webview. * Updated synchronously — unlike the session provider's currentSession which depends on @@ -1283,7 +1283,11 @@ export class AgentManagerProvider implements Disposable { if (!sec.collapsed) continue for (const id of state.getWorktreesInSection(sec.id)) skipped.add(id) } - this.statsPoller.syncSkips(skipped) + const stats = this.statsPoller.syncSkips(skipped) + if (!stats) return + const msg = { type: "agentManager.worktreeStats" as const, stats } + this.cachedWorktreeStats = msg + this.postToWebview(msg) } private pushState(): void { diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index b815658168d..30a79b827c5 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -58,10 +58,7 @@ export class GitStatsPoller { private lastHash: string | undefined private lastLocalHash: string | undefined private lastLocalStats: LocalStats | undefined - private lastStats: Record< - string, - { files: number; additions: number; deletions: number; ahead: number; behind: number } - > = {} + private lastStats: Record = {} private readonly intervalMs: number private readonly hiddenIntervalMs: number private readonly git: GitOps @@ -85,8 +82,14 @@ export class GitStatsPoller { } /** Replace the entire skip set with the given IDs. */ - syncSkips(ids: Set): void { + syncSkips(ids: Set): WorktreeStats[] | undefined { this.skipWorktreeIds = ids + const stats = Object.values(this.lastStats).filter((item) => !ids.has(item.worktreeId)) + if (stats.length === 0) return undefined + const hash = this.hash(stats) + if (hash === this.lastHash) return undefined + this.lastHash = hash + return stats } /** Pre-emptively exclude a single worktree (e.g. before deletion). */ @@ -163,8 +166,14 @@ export class GitStatsPoller { const missing = new Set( presence.degraded ? [] : presence.worktrees.filter((item) => item.missing).map((item) => item.worktreeId), ) - const active = worktrees.filter((wt) => !missing.has(wt.id) && !this.skipWorktreeIds.has(wt.id)) + const available = worktrees.filter((wt) => !missing.has(wt.id)) + const ids = new Set(available.map((wt) => wt.id)) + for (const id of Object.keys(this.lastStats)) { + if (!ids.has(id)) delete this.lastStats[id] + } + const active = available.filter((wt) => !this.skipWorktreeIds.has(wt.id)) if (active.length === 0) { + if (available.length > 0) return if (this.lastHash === "") return this.lastHash = "" this.lastStats = {} @@ -192,45 +201,29 @@ export class GitStatsPoller { return { worktreeId: wt.id, files, additions, deletions, ahead: ab.ahead, behind: ab.behind } } catch (err) { this.options.log(`Failed to fetch worktree stats for ${wt.branch} (${wt.path}):`, err) - const prev = this.lastStats[wt.id] - if (!prev) return undefined - return { - worktreeId: wt.id, - files: prev.files, - additions: prev.additions, - deletions: prev.deletions, - ahead: prev.ahead, - behind: prev.behind, - } + return this.lastStats[wt.id] } }), ) ).filter((item): item is WorktreeStats => !!item) - if (stats.length === 0) return + for (const item of stats) this.lastStats[item.worktreeId] = item - const hash = stats + const visible = Object.values(this.lastStats).filter((item) => !this.skipWorktreeIds.has(item.worktreeId)) + if (visible.length === 0) return + + const hash = this.hash(visible) + if (hash === this.lastHash) return + this.lastHash = hash + this.options.onStats(visible) + } + + private hash(stats: WorktreeStats[]): string { + return stats .map( (item) => `${item.worktreeId}:${item.files}:${item.additions}:${item.deletions}:${item.ahead}:${item.behind}`, ) .join("|") - if (hash === this.lastHash) return - this.lastHash = hash - this.lastStats = stats.reduce( - (acc, item) => { - acc[item.worktreeId] = { - files: item.files, - additions: item.additions, - deletions: item.deletions, - ahead: item.ahead, - behind: item.behind, - } - return acc - }, - {} as Record, - ) - - this.options.onStats(stats) } private async probeWorktreePresence(worktrees: Worktree[]): Promise { diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 5d5e37c5922..314aa077aee 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -590,6 +590,7 @@ interface ForkSessionIn { interface AbortIn { type: "abort" sessionID: string + queuedMessageIDs?: string[] } interface ContinueInWorktreeIn { diff --git a/packages/kilo-vscode/src/commands/heap-snapshot.ts b/packages/kilo-vscode/src/commands/heap-snapshot.ts new file mode 100644 index 00000000000..4b38111d946 --- /dev/null +++ b/packages/kilo-vscode/src/commands/heap-snapshot.ts @@ -0,0 +1,36 @@ +import * as vscode from "vscode" +import type { KiloConnectionService } from "../services/cli-backend/connection-service" + +export function registerHeapSnapshot(context: vscode.ExtensionContext, connectionService: KiloConnectionService): void { + context.subscriptions.push( + vscode.commands.registerCommand("kilo-code.new.takeHeapSnapshot", async () => { + try { + const file = await snapshot(connectionService) + vscode.window.showInformationMessage(`Heap snapshot written to ${file}`) + } catch (err) { + vscode.window.showErrorMessage(`Failed to write heap snapshot: ${message(err)}`) + } + }), + ) +} + +async function snapshot(connectionService: KiloConnectionService) { + await connectionService.getClientAsync() + const cfg = connectionService.getServerConfig() + if (!cfg) throw new Error("CLI server is not connected") + + const auth = Buffer.from(`kilo:${cfg.password}`).toString("base64") + const res = await fetch(`${cfg.baseUrl}/kilocode/heap/snapshot`, { + method: "POST", + headers: { + Authorization: `Basic ${auth}`, + }, + }) + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return (await res.json()) as string +} + +function message(err: unknown) { + if (err instanceof Error) return err.message + return String(err) +} diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index ec3f21980f4..450aba09be7 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -16,6 +16,7 @@ import { TelemetryProxy } from "./services/telemetry" import { registerCommitMessageService } from "./services/commit-message" import { registerCodeActions, registerTerminalActions, KiloCodeActionProvider } from "./services/code-actions" import { registerToggleAutoApprove } from "./commands/toggle-auto-approve" +import { registerHeapSnapshot } from "./commands/heap-snapshot" import { RemoteStatusService } from "./services/RemoteStatusService" // Activated via "onStartupFinished" (package.json) so that commands, code actions, keybindings, @@ -362,6 +363,8 @@ export function activate(context: vscode.ExtensionContext) { }, ) + registerHeapSnapshot(context, connectionService) + // Register code actions (editor context menus, terminal context menus, keyboard shortcuts) registerCodeActions(context, provider, agentManagerProvider) registerTerminalActions(context, provider, agentManagerProvider) diff --git a/packages/kilo-vscode/src/kilo-provider/abort.ts b/packages/kilo-vscode/src/kilo-provider/abort.ts new file mode 100644 index 00000000000..e295fea5454 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/abort.ts @@ -0,0 +1,21 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" + +export function parseQueued(value: unknown) { + if (!Array.isArray(value)) return [] + return value.filter((id): id is string => typeof id === "string") +} + +export async function abortSession(input: { + client: KiloClient + sessionID: string + dir: string + queuedMessageIDs: string[] +}) { + await input.client.session.abort({ sessionID: input.sessionID, directory: input.dir }, { throwOnError: true }) + + for (const mid of new Set(input.queuedMessageIDs)) { + await input.client.session + .deleteMessage({ sessionID: input.sessionID, messageID: mid, directory: input.dir }, { throwOnError: true }) + .catch((err) => console.error("[Kilo New] KiloProvider: Failed to remove queued message:", err)) + } +} diff --git a/packages/kilo-vscode/tests/unit/abort.test.ts b/packages/kilo-vscode/tests/unit/abort.test.ts new file mode 100644 index 00000000000..c9002a650c6 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/abort.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test" +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { abortSession, parseQueued } from "../../src/kilo-provider/abort" + +function client(calls: unknown[], fail = false) { + return { + session: { + abort: async (params: unknown, opts: unknown) => { + calls.push({ type: "abort", params, opts }) + if (fail) throw new Error("abort failed") + return { data: true } + }, + deleteMessage: async (params: unknown, opts: unknown) => { + calls.push({ type: "delete", params, opts }) + return { data: true } + }, + }, + } as unknown as KiloClient +} + +describe("parseQueued", () => { + it("keeps only string queued message ids", () => { + expect(parseQueued(["message_1", 2, null, "message_2", {}])).toEqual(["message_1", "message_2"]) + }) + + it("returns empty ids for invalid payloads", () => { + expect(parseQueued(undefined)).toEqual([]) + expect(parseQueued({ queuedMessageIDs: ["message_1"] })).toEqual([]) + }) +}) + +describe("abortSession", () => { + it("aborts before removing queued follow-up messages", async () => { + const calls: unknown[] = [] + + await abortSession({ + client: client(calls), + sessionID: "session_1", + dir: "/repo", + queuedMessageIDs: ["message_2", "message_3", "message_2"], + }) + + expect(calls).toEqual([ + { + type: "abort", + params: { sessionID: "session_1", directory: "/repo" }, + opts: { throwOnError: true }, + }, + { + type: "delete", + params: { sessionID: "session_1", messageID: "message_2", directory: "/repo" }, + opts: { throwOnError: true }, + }, + { + type: "delete", + params: { sessionID: "session_1", messageID: "message_3", directory: "/repo" }, + opts: { throwOnError: true }, + }, + ]) + }) + + it("does not remove queued messages when abort fails", async () => { + const calls: unknown[] = [] + + await expect( + abortSession({ + client: client(calls, true), + sessionID: "session_1", + dir: "/repo", + queuedMessageIDs: ["message_2"], + }), + ).rejects.toThrow("abort failed") + + expect(calls).toEqual([ + { + type: "abort", + params: { sessionID: "session_1", directory: "/repo" }, + opts: { throwOnError: true }, + }, + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-queue.test.ts b/packages/kilo-vscode/tests/unit/session-queue.test.ts new file mode 100644 index 00000000000..367362d07de --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-queue.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test" +import { activeUserMessageID, queuedUserMessageIDs } from "../../webview-ui/src/context/session-queue" +import type { Message } from "../../webview-ui/src/types/messages" + +const base = { + sessionID: "session", + createdAt: "2026-01-01T00:00:00.000Z", + time: { created: 1 }, +} + +const user = (id: string): Message => ({ ...base, id, role: "user" }) + +const assistant = (id: string, parentID: string, opts: Partial = {}): Message => ({ + ...base, + id, + parentID, + role: "assistant", + ...opts, +}) + +describe("queuedUserMessageIDs", () => { + it("keeps follow-ups queued before the first assistant exists", () => { + const messages = [user("message_1"), user("message_2")] + + expect(queuedUserMessageIDs(messages, { type: "busy" })).toEqual(["message_2"]) + }) + + it("keeps follow-ups queued after a pending assistant parent", () => { + const messages = [ + user("message_1"), + assistant("message_2", "message_1", { finish: "tool-calls" }), + user("message_3"), + ] + + expect(queuedUserMessageIDs(messages, { type: "busy" })).toEqual(["message_3"]) + }) + + it("keeps only later follow-ups queued after a terminal assistant", () => { + const messages = [ + user("message_1"), + assistant("message_2", "message_1", { finish: "stop" }), + user("message_3"), + user("message_4"), + ] + + expect(queuedUserMessageIDs(messages, { type: "busy" })).toEqual(["message_4"]) + }) + + it("returns no queued messages while idle", () => { + const messages = [user("message_1"), user("message_2")] + + expect(queuedUserMessageIDs(messages, { type: "idle" })).toEqual([]) + }) +}) + +describe("activeUserMessageID", () => { + it("uses the first pending user before the first assistant exists", () => { + const messages = [user("message_1"), user("message_2")] + + expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_1") + }) + + it("ignores terminal assistant updates without completed timestamps", () => { + const messages = [user("message_1"), assistant("message_2", "message_1", { finish: "stop" }), user("message_3")] + + expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_3") + }) + + it("keeps tool-call assistants active until their follow-up finishes", () => { + const messages = [ + user("message_1"), + assistant("message_2", "message_1", { finish: "tool-calls" }), + user("message_3"), + ] + + expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_1") + }) + + it("keeps unknown assistants active until cleanup finishes", () => { + const messages = [user("message_1"), assistant("message_2", "message_1", { finish: "unknown" }), user("message_3")] + + expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_1") + }) + + it("ignores aborted assistants without completed timestamps", () => { + const messages = [ + user("message_1"), + assistant("message_2", "message_1", { error: { name: "MessageAbortedError" } }), + user("message_3"), + ] + + expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_3") + }) +}) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png new file mode 100644 index 00000000000..fa65a4f8a84 --- /dev/null +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:123f32340a93af21146d08bb40738710bdd84099a27d0310316bb937360ca9f9 +size 14605 diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 02035c8cd1e..93ea4b965c3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -91,7 +91,6 @@ export const AssistantMessage: Component = (props) => { if (part.type !== "tool") return undefined const tp = part as unknown as ToolPart if (tp.tool !== "question") return undefined - if (tp.state?.status !== "pending" && tp.state?.status !== "running") return undefined return session.questions().find((q) => q.tool?.callID === tp.callID && q.tool?.messageID === tp.messageID) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index eee0d6a844a..2302cc1ee80 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -78,10 +78,9 @@ export const ChatView: Component = (props) => { onMount(() => { if (props.readonly) return const handler = (e: KeyboardEvent) => { - if (e.key === "Escape" && session.status() === "busy" && !e.defaultPrevented) { - e.preventDefault() - session.abort() - } + if (e.key !== "Escape" || session.status() === "idle" || e.defaultPrevented) return + e.preventDefault() + session.abort() } document.addEventListener("keydown", handler) onCleanup(() => document.removeEventListener("keydown", handler)) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 8828ca0020f..8f88e2a5ec5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -109,7 +109,7 @@ export const MessageList: Component = (props) => { role="log" aria-live="polite" > -
+
diff --git a/packages/kilo-vscode/webview-ui/src/context/session-queue.ts b/packages/kilo-vscode/webview-ui/src/context/session-queue.ts index ac64139585b..ae3613102bd 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-queue.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-queue.ts @@ -1,25 +1,54 @@ import type { Message, SessionStatusInfo } from "../types/messages" -// Find the user message whose turn the server is actively processing. -// Any user message after this one is "queued" (waiting for its turn). -export function activeUserMessageID(messages: Message[], status: SessionStatusInfo) { - // Walk backward to find a non-completed assistant — its parent is the active turn +function active(messages: Message[]) { for (let i = messages.length - 1; i >= 0; i -= 1) { const msg = messages[i] if (msg.role !== "assistant") continue if (typeof msg.time?.completed === "number") continue + if (msg.error) continue + if (msg.finish && !["tool-calls", "unknown"].includes(msg.finish)) continue if (!msg.parentID) break const parent = messages.find((item) => item.id === msg.parentID) if (parent?.role === "user") return parent.id break } - // No pending assistant found — if busy, the last user message is the active turn - if (status.type === "idle") return undefined + return undefined +} - for (let i = messages.length - 1; i >= 0; i -= 1) { +function pending(messages: Message[]) { + const done = (() => { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const msg = messages[i] + if (msg.role !== "assistant") continue + if (typeof msg.time?.completed === "number") return i + if (msg.error) return i + if (msg.finish && !["tool-calls", "unknown"].includes(msg.finish)) return i + } + return -1 + })() + + for (let i = done + 1; i < messages.length; i += 1) { if (messages[i].role === "user") return messages[i].id } return undefined } + +// Find the user message whose turn the server is actively processing. +// Any user message after this one is "queued" (waiting for its turn). +export function activeUserMessageID(messages: Message[], status: SessionStatusInfo) { + const id = active(messages) + if (id) return id + if (status.type === "idle") return undefined + return pending(messages) +} + +export function queuedUserMessageIDs(messages: Message[], status: SessionStatusInfo) { + if (status.type === "idle") return [] + const users = messages.filter((msg) => msg.role === "user") + const id = active(messages) ?? pending(messages) + const idx = id ? users.findIndex((msg) => msg.id === id) : -1 + if (idx < 0) return [] + return users.slice(idx + 1).map((msg) => msg.id) +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index ed1c38b7ef3..7ca84260513 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -44,6 +44,7 @@ import { import { Identifier } from "../utils/id" import { resolveModelSelection } from "./model-selection" import { resolveSessionAgent } from "./session-agent" +import { queuedUserMessageIDs } from "./session-queue" import { KILO_AUTO, parseModelString } from "../../../src/shared/provider-model" const RECENT_LIMIT = 5 @@ -1488,9 +1489,12 @@ export const SessionProvider: ParentComponent = (props) => { return } + const queuedMessageIDs = queuedUserMessageIDs(messages(), statusInfo()) + vscode.postMessage({ type: "abort", sessionID, + queuedMessageIDs, }) } diff --git a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx index 93c64ab4ade..53619d71d36 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -8,10 +8,11 @@ */ import type { Meta, StoryObj } from "storybook-solidjs-vite" -import { StoryProviders, mockSessionValue } from "./StoryProviders" +import { StoryProviders, defaultMockData, mockSessionValue } from "./StoryProviders" import { ChatView } from "../components/chat/ChatView" import { TaskHeader } from "../components/chat/TaskHeader" import { QuestionDock } from "../components/chat/QuestionDock" +import { MessageList } from "../components/chat/MessageList" import { SessionContext } from "../context/session" import { ServerContext } from "../context/server" import type { QuestionRequest, TodoItem } from "../types/messages" @@ -172,6 +173,100 @@ export const QuestionDockManyOptions: Story = { ), } +const toolUserID = "user-msg-spacing-001" +const toolAssistantID = "asst-msg-spacing-001" +const queuedUserID = "user-msg-spacing-002" +const toolNow = 1_700_000_000_000 +const spacingMessages = [ + { + id: toolUserID, + sessionID: SESSION_ID, + role: "user", + time: { created: toolNow - 9000 }, + }, + { + id: toolAssistantID, + sessionID: SESSION_ID, + role: "assistant", + parentID: toolUserID, + time: { created: toolNow - 8000 }, + modelID: "claude-sonnet-4-20250514", + providerID: "anthropic", + mode: "default", + agent: "default", + path: { cwd: "/project", root: "/project" }, + }, + { + id: queuedUserID, + sessionID: SESSION_ID, + role: "user", + time: { created: toolNow - 1000 }, + }, +] +const spacingParts = { + [toolUserID]: [ + { + id: "part-user-spacing-001", + sessionID: SESSION_ID, + messageID: toolUserID, + type: "text", + text: "Run a shell command and stop so I can test the spacing.", + }, + ], + [toolAssistantID]: [ + { + id: "part-bash-spacing-001", + sessionID: SESSION_ID, + messageID: toolAssistantID, + type: "tool", + callID: "call-bash-spacing-001", + tool: "bash", + state: { + status: "completed", + input: { command: "pwd", description: "Print current directory" }, + output: "/Users/marius/Documents/git/kilocode/.kilo/worktrees/zest-kettledrum", + title: "pwd", + metadata: {}, + time: { start: toolNow - 7000, end: toolNow - 6500 }, + }, + }, + ], + [queuedUserID]: [ + { + id: "part-user-spacing-002", + sessionID: SESSION_ID, + messageID: queuedUserID, + type: "text", + text: "ok", + }, + ], +} +const spacingData = { + ...defaultMockData, + message: { [SESSION_ID]: spacingMessages }, + part: spacingParts, +} + +export const MessageListToolToQueuedUserSpacing: Story = { + name: "MessageList — tool to queued user spacing", + render: () => { + const session = { + ...mockSessionValue({ id: SESSION_ID, status: "idle" }), + messages: () => spacingMessages, + userMessages: () => spacingMessages.filter((msg) => msg.role === "user"), + } + return ( + + +
+ +
+
+
+ ) + }, +} + // --------------------------------------------------------------------------- // TaskHeader with todos // --------------------------------------------------------------------------- diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css index ba6545819a0..04d89fbbcba 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css @@ -63,6 +63,13 @@ font-size: 13px; } +.message-list-content { + display: flex; + min-height: 100%; + flex-direction: column; + gap: 12px; +} + .message-list-content-empty { display: flex; min-height: 100%; @@ -153,10 +160,6 @@ padding: 0 4px; } -.vscode-session-turn + .vscode-session-turn { - margin-top: 12px; -} - .vscode-session-turn-user { width: 100%; } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 7a2254bfa5f..5ca600e7ef1 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -125,6 +125,7 @@ export interface Message { summary?: { title?: string; body?: string; diffs?: unknown[] } | boolean cost?: number tokens?: TokenUsage + finish?: string } // File diff info (matches Snapshot.FileDiff from CLI backend) @@ -1593,6 +1594,7 @@ export interface SendMessageRequest { export interface AbortRequest { type: "abort" sessionID: string + queuedMessageIDs?: string[] } export interface RevertSessionRequest { diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 6e9ec397e1b..fdef08281f0 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1 +1,29 @@ # @kilocode/cli + +## 7.2.11 + +### Patch Changes + +- [#8898](https://github.com/Kilo-Org/kilocode/pull/8898) [`4a69a3e`](https://github.com/Kilo-Org/kilocode/commit/4a69a3e0d11a041827c1c68e1a47f84ed0f4c893) - Fixed default model falling back to the free model after login or org switch by invalidating cached provider state when auth changes. + +- [#8996](https://github.com/Kilo-Org/kilocode/pull/8996) [`58ff01a`](https://github.com/Kilo-Org/kilocode/commit/58ff01a2bcac172ae93e4213046a3e9c6c353f59) Thanks [@kilo-code-bot](https://github.com/apps/kilo-code-bot)! - Include pnpm-lock.yaml and yarn.lock in the .kilo/.gitignore so lockfiles from alternative package managers don't appear as untracked files + +- [`4937759`](https://github.com/Kilo-Org/kilocode/commit/4937759bf46737a9300d4effedd627676ab4ca68) - Merged upstream opencode changes from v1.3.10: + - Subagent tool calls stay clickable while pending + - Improved storage migration reliability + - Better muted text contrast in Catppuccin themes + +- [`4937759`](https://github.com/Kilo-Org/kilocode/commit/4937759bf46737a9300d4effedd627676ab4ca68) - Merged upstream opencode changes from v1.3.6: + - Fixed token usage double-counting for Anthropic and Amazon Bedrock providers + - Fixed variant dialog search filtering + +- [`4937759`](https://github.com/Kilo-Org/kilocode/commit/4937759bf46737a9300d4effedd627676ab4ca68) - Merged upstream opencode changes from v1.3.7: + - Added first-class PowerShell support on Windows + - Plugin installs now preserve JSONC comments in configuration files + - Improved variant modal behavior to be less intrusive + +- [#9047](https://github.com/Kilo-Org/kilocode/pull/9047) [`bea8878`](https://github.com/Kilo-Org/kilocode/commit/bea88788f4530f57d210b98cd7205168cd8f9ae9) - Continue queued follow-up prompts after the active session turn finishes. + +- Updated dependencies [[`4d2f553`](https://github.com/Kilo-Org/kilocode/commit/4d2f55343b7403625c60de09460d01ab8ae268f7)]: + - @kilocode/kilo-gateway@7.2.11 + - @kilocode/kilo-telemetry@7.2.11 diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 76cdcfb288f..5441c2f39fa 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.10", + "version": "7.2.11", "name": "@kilocode/cli", "type": "module", "license": "MIT", @@ -8,8 +8,8 @@ "scripts": { "prepare": "effect-language-service patch || true", "typecheck": "tsgo --noEmit", - "test": "bun test --timeout 30000", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test": "bun run script/test-runner.ts", + "test:ci": "bun run script/test-runner.ts --ci", "build": "bun run script/build.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts", diff --git a/packages/opencode/script/test-runner.ts b/packages/opencode/script/test-runner.ts new file mode 100644 index 00000000000..d221cb45cc7 --- /dev/null +++ b/packages/opencode/script/test-runner.ts @@ -0,0 +1,346 @@ +// kilocode_change - new file +// +// Custom test runner that executes each test file in its own isolated process. +// Prevents cross-contamination between test files by ensuring separate PIDs, +// temp directories, in-memory databases, and environment state. + +import os from "os" +import path from "path" +import fs from "fs/promises" + +const root = path.resolve(import.meta.dir, "..") +const argv = process.argv.slice(2) + +// --------------------------------------------------------------------------- +// Help +// --------------------------------------------------------------------------- + +if (argv.includes("--help") || argv.includes("-h")) { + console.log( + [ + "", + "Usage: bun run script/test-runner.ts [options] [patterns...]", + "", + "Runs test files in isolated parallel processes to prevent cross-contamination.", + "", + "Options:", + " --ci Enable JUnit XML output to .artifacts/unit/junit.xml", + " --concurrency Max parallel processes (default: CPU count)", + " --timeout Per-test timeout passed to bun test (default: 30000)", + " --file-timeout Per-file process timeout (default: 300000)", + " --bail Stop on first failure", + " --verbose Show full output for every file", + " -h, --help Show this help", + "", + "Positional:", + " [patterns...] Filter test files by substring match", + "", + ].join("\n"), + ) + process.exit(0) +} + +// --------------------------------------------------------------------------- +// CLI parsing +// --------------------------------------------------------------------------- + +function opt(name: string, fallback: number) { + const i = argv.indexOf(`--${name}`) + return i >= 0 && i + 1 < argv.length ? Number(argv[i + 1]) || fallback : fallback +} + +const ci = argv.includes("--ci") +const bail = argv.includes("--bail") +const verbose = argv.includes("--verbose") +const concurrency = opt("concurrency", os.cpus().length) +const timeout = opt("timeout", 30000) +const deadline = opt("file-timeout", 300000) + +const valued = new Set(["--concurrency", "--timeout", "--file-timeout"]) +const patterns = argv.filter((arg, i) => { + if (arg.startsWith("-")) return false + if (i > 0 && valued.has(argv[i - 1])) return false + return true +}) + +// --------------------------------------------------------------------------- +// Colors +// --------------------------------------------------------------------------- + +const tty = !!process.stdout.isTTY +const green = (s: string) => (tty ? `\x1b[32m${s}\x1b[0m` : s) +const red = (s: string) => (tty ? `\x1b[31m${s}\x1b[0m` : s) +const dim = (s: string) => (tty ? `\x1b[2m${s}\x1b[0m` : s) +const bold = (s: string) => (tty ? `\x1b[1m${s}\x1b[0m` : s) + +// --------------------------------------------------------------------------- +// File discovery +// --------------------------------------------------------------------------- + +const glob = new Bun.Glob("**/*.test.{ts,tsx}") +const all = (await Array.fromAsync(glob.scan({ cwd: path.join(root, "test") }))).sort() + +const files = + patterns.length > 0 ? all.filter((f) => patterns.some((p) => f.includes(p) || path.join("test", f).includes(p))) : all + +if (files.length === 0) { + console.log("No test files found") + process.exit(0) +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type Result = { + file: string + passed: boolean + code: number + stdout: string + stderr: string + duration: number + timedout: boolean +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +const xmldir = ci ? path.join(os.tmpdir(), `opencode-junit-${process.pid}`) : "" +if (ci) await fs.mkdir(xmldir, { recursive: true }) + +const counter = { done: 0 } +const pad = String(files.length).length + +// --------------------------------------------------------------------------- +// Run a single test file +// --------------------------------------------------------------------------- + +async function run(file: string): Promise { + const target = path.join("test", file) + const cmd = ["bun", "test", target, "--timeout", String(timeout)] + + if (ci) { + const name = file.replace(/[/\\]/g, "_") + ".xml" + cmd.push("--reporter=junit", `--reporter-outfile=${path.join(xmldir, name)}`) + } + + const start = performance.now() + const killed = { value: false } + + const proc = Bun.spawn(cmd, { + cwd: root, + stdout: "pipe", + stderr: "pipe", + }) + + const timer = setTimeout(() => { + killed.value = true + proc.kill() + }, deadline) + + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + + clearTimeout(timer) + + return { + file, + passed: code === 0, + code, + stdout, + stderr, + duration: performance.now() - start, + timedout: killed.value, + } +} + +// --------------------------------------------------------------------------- +// Report a single result +// --------------------------------------------------------------------------- + +function report(result: Result) { + counter.done++ + const idx = String(counter.done).padStart(pad) + const secs = (result.duration / 1000).toFixed(1) + + if (result.timedout) { + console.log( + `[${idx}/${files.length}] ${red("TIME")} ${result.file} ${dim(`(${secs}s - exceeded ${deadline / 1000}s)`)}`, + ) + return + } + + if (!result.passed) { + console.log(`[${idx}/${files.length}] ${red("FAIL")} ${result.file} ${dim(`(${secs}s)`)}`) + if (verbose && result.stderr.trim()) console.log(result.stderr) + if (verbose && result.stdout.trim()) console.log(result.stdout) + return + } + + console.log(`[${idx}/${files.length}] ${green("PASS")} ${result.file} ${dim(`(${secs}s)`)}`) + if (verbose && result.stdout.trim()) console.log(dim(result.stdout)) +} + +// --------------------------------------------------------------------------- +// Parallel execution +// --------------------------------------------------------------------------- + +console.log(`\nRunning ${bold(String(files.length))} test files with concurrency ${bold(String(concurrency))}\n`) + +const start = performance.now() +const results: Result[] = [] +const queue = [...files] +const stopped = { value: false } + +const workers = Array.from({ length: Math.min(concurrency, files.length) }, async () => { + while (queue.length > 0 && !stopped.value) { + const file = queue.shift()! + const result = await run(file) + results.push(result) + report(result) + if (bail && !result.passed) stopped.value = true + } +}) + +await Promise.all(workers) + +const elapsed = (performance.now() - start) / 1000 + +// --------------------------------------------------------------------------- +// Failure details +// --------------------------------------------------------------------------- + +const failures = results.filter((r) => !r.passed).sort((a, b) => a.file.localeCompare(b.file)) + +if (failures.length > 0 && !verbose) { + console.log(`\n${bold(red("--- FAILURES ---"))}\n`) + for (const f of failures) { + const tag = f.timedout ? " (TIMED OUT)" : "" + console.log(`${bold(red(f.file))}${tag}:`) + const output = (f.stderr || f.stdout).trim() + if (output) + console.log( + output + .split("\n") + .map((l) => " " + l) + .join("\n"), + ) + console.log() + } +} + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +const passed = results.filter((r) => r.passed).length + +console.log( + `\n${bold(String(results.length))} files | ` + + `${green(passed + " passed")} | ` + + `${failures.length > 0 ? red(failures.length + " failed") : failures.length + " failed"} | ` + + `${elapsed.toFixed(1)}s\n`, +) + +// --------------------------------------------------------------------------- +// JUnit XML merge (CI mode) +// --------------------------------------------------------------------------- + +if (ci) { + await merge() + await fs.rm(xmldir, { recursive: true, force: true }).catch((err) => { + console.error("cleanup failed:", err) + }) +} + +process.exit(failures.length > 0 ? 1 : 0) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function merge() { + const dir = path.join(root, ".artifacts", "unit") + await fs.mkdir(dir, { recursive: true }) + + const suites: string[] = [] + const counts = { tests: 0, failures: 0, errors: 0 } + + for (const file of files) { + const name = file.replace(/[/\\]/g, "_") + ".xml" + const fpath = path.join(xmldir, name) + const found = await Bun.file(fpath).exists() + + if (found) { + const content = await Bun.file(fpath).text() + const extracted = extract(content) + if (extracted) { + suites.push(extracted) + counts.tests += attr(extracted, "tests") + counts.failures += attr(extracted, "failures") + counts.errors += attr(extracted, "errors") + continue + } + } + + // No valid XML produced - generate synthetic entry for failed files + const result = results.find((r) => r.file === file) + if (!result || result.passed) continue + + const secs = (result.duration / 1000).toFixed(3) + const msg = result.timedout + ? `Test file timed out after ${deadline / 1000}s` + : `Test process exited with code ${result.code}` + const detail = esc((result.stderr || result.stdout || msg).slice(0, 10000)) + + suites.push( + ` \n` + + ` \n` + + ` ${detail}\n` + + ` \n` + + ` `, + ) + counts.tests++ + counts.failures++ + } + + const body = [ + '', + ``, + ...suites, + "", + "", + ].join("\n") + + await Bun.write(path.join(dir, "junit.xml"), body) +} + +function extract(content: string, from = 0): string { + const open = "/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b64843f1cc2..aff56642555 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1424,7 +1424,17 @@ export namespace Config { if (!hasIgnore) { yield* fs.writeFileString( gitignore, - ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), + // kilocode_change start - added pnpm-lock.yaml and yarn.lock (not in upstream) + [ + "node_modules", + "package.json", + "package-lock.json", + "pnpm-lock.yaml", + "bun.lock", + "yarn.lock", + ".gitignore", + ].join("\n"), + // kilocode_change end ) } diff --git a/packages/opencode/src/kilocode/cli/heap-snapshot.ts b/packages/opencode/src/kilocode/cli/heap-snapshot.ts new file mode 100644 index 00000000000..6bb318b8dbf --- /dev/null +++ b/packages/opencode/src/kilocode/cli/heap-snapshot.ts @@ -0,0 +1,13 @@ +import path from "path" +import { writeHeapSnapshot } from "node:v8" +import { Global } from "@/global" + +export namespace HeapSnapshot { + export function write() { + const file = path.join( + Global.Path.log, + `heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`, + ) + return writeHeapSnapshot(file) + } +} diff --git a/packages/opencode/src/kilocode/commit-message/git-context.ts b/packages/opencode/src/kilocode/commit-message/git-context.ts index d81e8a62cfb..af4cea296f7 100644 --- a/packages/opencode/src/kilocode/commit-message/git-context.ts +++ b/packages/opencode/src/kilocode/commit-message/git-context.ts @@ -121,14 +121,14 @@ const LOCK_FILES = new Set([ "devcontainer.lock.json", ]) -const MAX_DIFF_LENGTH = 4000 +export const MAX_DIFF_LENGTH = 4000 -function isLockFile(filepath: string): boolean { +export function isLockFile(filepath: string): boolean { const name = filepath.split("/").pop() ?? filepath return LOCK_FILES.has(name) } -function git(args: string[], cwd: string): string { +export function git(args: string[], cwd: string): string { const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", @@ -138,7 +138,7 @@ function git(args: string[], cwd: string): string { return result.stdout.toString().trimEnd() } -function parseNameStatus(output: string): Array<{ status: string; path: string }> { +export function parseNameStatus(output: string): Array<{ status: string; path: string }> { if (!output) return [] return output.split("\n").map((line) => { const [status, ...rest] = line.split("\t") @@ -153,7 +153,7 @@ function parseNameStatus(output: string): Array<{ status: string; path: string } }) } -function parsePorcelain(output: string): Array<{ status: string; path: string }> { +export function parsePorcelain(output: string): Array<{ status: string; path: string }> { if (!output) return [] return output .split("\n") @@ -165,7 +165,7 @@ function parsePorcelain(output: string): Array<{ status: string; path: string }> }) } -function mapStatus(code: string): FileChange["status"] { +export function mapStatus(code: string): FileChange["status"] { if (code.startsWith("R")) return "renamed" if (code === "A" || code === "??" || code === "?") return "added" if (code === "D") return "deleted" @@ -173,7 +173,7 @@ function mapStatus(code: string): FileChange["status"] { return "modified" } -function isUntracked(code: string): boolean { +export function isUntracked(code: string): boolean { return code === "??" || code === "?" } diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts new file mode 100644 index 00000000000..54adcb6c407 --- /dev/null +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -0,0 +1,86 @@ +import { Effect } from "effect" +import { MessageV2 } from "@/session/message-v2" +import { MessageID, SessionID } from "@/session/schema" + +type Slot = { + readonly version: number + readonly previous: Promise + readonly done: PromiseWithResolvers + readonly tail: Promise +} + +export namespace KiloSessionPromptQueue { + const tails = new Map>() + const versions = new Map() + const targets = new Map() + + const version = (sessionID: SessionID) => versions.get(sessionID) ?? 0 + const settle = (promise: Promise) => + promise.then( + () => undefined, + () => undefined, + ) + + export function cancel(sessionID: SessionID) { + return Effect.sync(() => { + versions.set(sessionID, version(sessionID) + 1) + }) + } + + export function scope(sessionID: SessionID, messages: MessageV2.WithParts[]) { + const target = targets.get(sessionID) + if (!target) return messages + + const hidden = new Set( + messages.filter((item) => item.info.role === "user" && item.info.id > target).map((item) => item.info.id), + ) + const visible = messages.filter((item) => { + if (item.info.role === "user") return item.info.id <= target + if (item.info.role === "assistant") return !hidden.has(item.info.parentID) + return true + }) + return visible + } + + export function enqueue( + sessionID: SessionID, + target: MessageID, + work: Effect.Effect, + cancelled: Effect.Effect, + ): Effect.Effect { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = tails.get(sessionID) ?? Promise.resolve() + const done = Promise.withResolvers() + // Keep later queued prompts moving; each caller still observes its own failure. + const tail = settle(previous).then(() => done.promise) + tails.set(sessionID, tail) + return { version: version(sessionID), previous, done, tail } satisfies Slot + }), + (slot) => + Effect.promise(() => settle(slot.previous)).pipe( + Effect.flatMap(() => { + if (slot.version !== version(sessionID)) return cancelled + return Effect.acquireUseRelease( + Effect.sync(() => { + targets.set(sessionID, target) + }), + () => work, + () => + Effect.sync(() => { + if (targets.get(sessionID) === target) targets.delete(sessionID) + }), + ) + }), + ), + (slot) => + Effect.sync(() => { + slot.done.resolve() + if (tails.get(sessionID) !== slot.tail) return + tails.delete(sessionID) + versions.delete(sessionID) + targets.delete(sessionID) + }), + ) + } +} diff --git a/packages/opencode/src/server/instance/kilocode.ts b/packages/opencode/src/server/instance/kilocode.ts index a1566ae7d2a..27c268e2e76 100644 --- a/packages/opencode/src/server/instance/kilocode.ts +++ b/packages/opencode/src/server/instance/kilocode.ts @@ -9,10 +9,33 @@ import { Agent } from "../../agent/agent" import { lazy } from "../../util/lazy" import { errors } from "../error" import { SessionImportRoutes } from "../../kilocode/session-import/routes" +import { HeapSnapshot } from "../../kilocode/cli/heap-snapshot" export const KilocodeRoutes = lazy(() => new Hono() .route("/session-import", SessionImportRoutes()) + .post( + "/heap/snapshot", + describeRoute({ + summary: "Write heap snapshot", + description: "Write a heap snapshot for the CLI process to the log directory.", + operationId: "kilocode.heap.snapshot", + responses: { + 200: { + description: "Heap snapshot file path", + content: { + "application/json": { + schema: resolver(z.string()), + }, + }, + }, + ...errors(400), + }, + }), + async (c) => { + return c.json(HeapSnapshot.write()) + }, + ) .post( "/skill/remove", describeRoute({ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3dab87a52be..d9c7f9145d2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2,6 +2,7 @@ import path from "path" import os from "os" import fs from "fs/promises" import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change +import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change import { KiloSession } from "@/kilocode/session" // kilocode_change import z from "zod" import { SessionID, MessageID, PartID } from "./schema" @@ -128,6 +129,7 @@ export namespace SessionPrompt { const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { yield* elog.info("cancel", { sessionID }) + yield* KiloSessionPromptQueue.cancel(sessionID) // kilocode_change - drop queued follow-up loops on abort yield* state.cancel(sessionID) }) @@ -1290,6 +1292,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID) yield* revert.cleanup(session) + // kilocode_change start - persist queued prompts immediately while serializing each follow-up loop const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) @@ -1303,9 +1306,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (input.noReply === true) return message - return yield* loop({ sessionID: input.sessionID }) + return yield* KiloSessionPromptQueue.enqueue( + input.sessionID, + message.info.id, + loop({ sessionID: input.sessionID }), + lastAssistant(input.sessionID), + ) }, ) + // kilocode_change end const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) { // kilocode_change start - retry when cancel races before shellImpl writes messages @@ -1339,6 +1348,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* slog.info("loop", { step }) let msgs = yield* MessageV2.filterCompactedEffect(sessionID) + msgs = KiloSessionPromptQueue.scope(sessionID, msgs) // kilocode_change - hide later queued prompts let lastUser: MessageV2.User | undefined let lastAssistant: MessageV2.Assistant | undefined @@ -1370,6 +1380,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the lastAssistant?.finish && !["tool-calls"].includes(lastAssistant.finish) && !hasToolCalls && + lastAssistant.parentID === lastUser.id && // kilocode_change - unrelated later assistants do not answer this turn lastUser.id < lastAssistant.id ) { // kilocode_change start - ask follow-up when plan_exit tool was called diff --git a/packages/opencode/test/kilocode/commit-message/git-context.test.ts b/packages/opencode/test/kilocode/commit-message/git-context.test.ts index 37d355b1977..0f0b0d22034 100644 --- a/packages/opencode/test/kilocode/commit-message/git-context.test.ts +++ b/packages/opencode/test/kilocode/commit-message/git-context.test.ts @@ -1,270 +1,194 @@ -import { describe, expect, test, beforeEach, mock } from "bun:test" +import { describe, expect, test } from "bun:test" +import { $ } from "bun" +import * as fs from "fs/promises" +import path from "path" +import { tmpdir } from "../../fixture/fixture" +import { + getGitContext, + isLockFile, + parseNameStatus, + parsePorcelain, + mapStatus, + isUntracked, + MAX_DIFF_LENGTH, +} from "../../../src/kilocode/commit-message/git-context" -// Mock Bun.spawnSync via mock.module so it integrates properly with bun:test -// and doesn't conflict with other test files that mock "../git-context". -const spawnSyncResults: Record = {} - -function setGitOutput(args: string, output: string) { - spawnSyncResults[args] = output -} - -function clearGitOutputs() { - for (const key of Object.keys(spawnSyncResults)) { - delete spawnSyncResults[key] +// ── Helper: stage files in a temp git repo ────────────────────────── +async function stage(dir: string, files: Record) { + for (const [file, text] of Object.entries(files)) { + const target = path.join(dir, file) + await fs.mkdir(path.dirname(target), { recursive: true }) + await Bun.write(target, text) + await $`git add ${file}`.cwd(dir).quiet() } } -// Override the git-context module with a version that uses our mock spawnSync. -// This avoids conflicts with generate.test.ts which also mocks this module. -mock.module("../../../src/kilocode/commit-message/git-context", () => { - function git(args: string[], cwd: string): string { - const key = args.join(" ") - return spawnSyncResults[key] ?? "" - } - - const LOCK_FILES = new Set([ - "package-lock.json", - "npm-shrinkwrap.json", - "yarn.lock", - "pnpm-lock.yaml", - "shrinkwrap.yaml", - "bun.lockb", - "bun.lock", - ".pnp.js", - ".pnp.cjs", - "jspm.lock", - "Pipfile.lock", - "poetry.lock", - "pdm.lock", - ".pdm-lock.toml", - "uv.lock", - "conda-lock.yml", - "pylock.toml", - "Gemfile.lock", - "composer.lock", - "gradle.lockfile", - "lockfile.json", - "dependency-lock.json", - "dependency-reduced-pom.xml", - "coursier.lock", - "build.sbt.lock", - "packages.lock.json", - "paket.lock", - "project.assets.json", - "Cargo.lock", - "go.sum", - "Gopkg.lock", - "glide.lock", - "build.zig.zon.lock", - "dune.lock", - "opam.lock", - "Package.resolved", - "Podfile.lock", - "Cartfile.resolved", - "pubspec.lock", - "mix.lock", - "rebar.lock", - "stack.yaml.lock", - "cabal.project.freeze", - "exact-dependencies.json", - "shard.lock", - "Manifest.toml", - "JuliaManifest.toml", - "renv.lock", - "packrat.lock", - "nimble.lock", - "dub.selections.json", - "rocks.lock", - "carton.lock", - "cpanfile.snapshot", - "conan.lock", - "vcpkg-lock.json", - ".terraform.lock.hcl", - "Berksfile.lock", - "Puppetfile.lock", - "MODULE.bazel.lock", - "flake.lock", - "deno.lock", - "devcontainer.lock.json", - ]) - - const MAX_DIFF_LENGTH = 4000 - - function isLockFile(filepath: string): boolean { - const name = filepath.split("/").pop() ?? filepath - return LOCK_FILES.has(name) - } - - function parseNameStatus(output: string): Array<{ status: string; path: string }> { - if (!output) return [] - return output.split("\n").map((line) => { - const [status, ...rest] = line.split("\t") - const path = status!.startsWith("R") ? (rest[1] ?? rest[0]) : rest.join("\t") - return { status: status!, path } - }) - } - - function parsePorcelain(output: string): Array<{ status: string; path: string }> { - if (!output) return [] - return output - .split("\n") - .filter((line) => line.length > 0) - .map((line) => { - const xy = line.slice(0, 2) - const filepath = line.slice(3) - return { status: xy.trim(), path: filepath } - }) - } - - type FileStatus = "added" | "modified" | "deleted" | "renamed" - - function mapStatus(code: string): FileStatus { - if (code.startsWith("R")) return "renamed" - if (code === "A" || code === "??" || code === "?") return "added" - if (code === "D") return "deleted" - if (code === "M") return "modified" - return "modified" - } - - function isUntracked(code: string): boolean { - return code === "??" || code === "?" - } - - async function getGitContext(repoPath: string, selectedFiles?: string[]) { - const branch = git(["branch", "--show-current"], repoPath) || "HEAD" - const log = git(["log", "--oneline", "-5"], repoPath) - const recentCommits = log ? log.split("\n") : [] - - const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath)) - const useStaged = staged.length > 0 - const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath)) - - const selected = selectedFiles ? new Set(selectedFiles) : undefined - - const files: Array<{ status: FileStatus; path: string; diff: string }> = [] - for (const entry of raw) { - if (isLockFile(entry.path)) continue - if (selected && !selected.has(entry.path)) continue - - const status = mapStatus(entry.status) - const untracked = isUntracked(entry.status) - - let diff: string - if (untracked) { - diff = `New untracked file: ${entry.path}` - } else if (status === "deleted") { - diff = useStaged - ? git(["diff", "--cached", "--", entry.path], repoPath) - : git(["diff", "--", entry.path], repoPath) - } else { - const raw = useStaged - ? git(["diff", "--cached", "--", entry.path], repoPath) - : git(["diff", "--", entry.path], repoPath) - if (raw.includes("Binary files") || raw.includes("GIT binary patch")) { - diff = `Binary file ${entry.path} has been modified` - } else { - diff = raw - } - } - - if (diff.length > MAX_DIFF_LENGTH) { - diff = diff.slice(0, MAX_DIFF_LENGTH) + "\n... [truncated]" - } - - files.push({ status, path: entry.path, diff }) - } - - return { branch, recentCommits, files } - } - - return { getGitContext } -}) - -import { getGitContext } from "../../../src/kilocode/commit-message/git-context" +// ── Pure-function unit tests (no git needed) ──────────────────────── describe("commit-message.git-context", () => { - beforeEach(() => { - clearGitOutputs() - // Defaults - setGitOutput("branch --show-current", "main") - setGitOutput("log --oneline -5", "abc1234 initial commit") - setGitOutput("diff --name-status --cached", "") - setGitOutput("status --porcelain", "") + describe("parseNameStatus", () => { + test("parses added file", () => { + const result = parseNameStatus("A\tsrc/new-file.ts") + expect(result).toEqual([{ status: "A", path: "src/new-file.ts" }]) + }) + + test("parses modified file", () => { + const result = parseNameStatus("M\tsrc/existing.ts") + expect(result).toEqual([{ status: "M", path: "src/existing.ts" }]) + }) + + test("parses deleted file", () => { + const result = parseNameStatus("D\tsrc/removed.ts") + expect(result).toEqual([{ status: "D", path: "src/removed.ts" }]) + }) + + test("parses renamed file using new path", () => { + const result = parseNameStatus("R100\told-name.ts\tnew-name.ts") + expect(result).toEqual([{ status: "R100", path: "new-name.ts" }]) + }) + + test("parses multiple entries", () => { + const result = parseNameStatus("M\tsrc/a.ts\nA\tsrc/b.ts") + expect(result).toHaveLength(2) + expect(result[0]!.path).toBe("src/a.ts") + expect(result[1]!.path).toBe("src/b.ts") + }) + + test("returns empty array for empty input", () => { + expect(parseNameStatus("")).toEqual([]) + }) }) - // NOTE: git() trims stdout, which eats the leading space of the first - // porcelain line. We use staged (--name-status) tests for path-sensitive - // assertions and only use porcelain for behavior tests where this is acceptable. + describe("parsePorcelain", () => { + test("parses untracked file", () => { + const result = parsePorcelain("?? src/brand-new.ts") + expect(result).toEqual([{ status: "??", path: "src/brand-new.ts" }]) + }) + + test("parses modified file", () => { + const result = parsePorcelain(" M src/changed.ts") + expect(result).toEqual([{ status: "M", path: "src/changed.ts" }]) + }) + + test("returns empty array for empty input", () => { + expect(parsePorcelain("")).toEqual([]) + }) + + test("filters blank lines", () => { + const result = parsePorcelain("?? a.ts\n\n?? b.ts") + expect(result).toHaveLength(2) + }) + }) + + describe("mapStatus", () => { + test("maps R-prefix to renamed", () => { + expect(mapStatus("R100")).toBe("renamed") + expect(mapStatus("R050")).toBe("renamed") + }) + + test("maps A to added", () => { + expect(mapStatus("A")).toBe("added") + }) + + test("maps ?? to added", () => { + expect(mapStatus("??")).toBe("added") + }) + + test("maps ? to added", () => { + expect(mapStatus("?")).toBe("added") + }) + + test("maps D to deleted", () => { + expect(mapStatus("D")).toBe("deleted") + }) + + test("maps M to modified", () => { + expect(mapStatus("M")).toBe("modified") + }) + + test("maps unknown codes to modified", () => { + expect(mapStatus("X")).toBe("modified") + }) + }) + + describe("isUntracked", () => { + test("returns true for ??", () => { + expect(isUntracked("??")).toBe(true) + }) + + test("returns true for ?", () => { + expect(isUntracked("?")).toBe(true) + }) + + test("returns false for other codes", () => { + expect(isUntracked("M")).toBe(false) + expect(isUntracked("A")).toBe(false) + }) + }) + + describe("isLockFile", () => { + test("detects package-lock.json", () => { + expect(isLockFile("package-lock.json")).toBe(true) + }) + + test("detects yarn.lock", () => { + expect(isLockFile("yarn.lock")).toBe(true) + }) + + test("detects lock files in subdirectories", () => { + expect(isLockFile("packages/api/package-lock.json")).toBe(true) + }) + + test("detects various lock files", () => { + expect(isLockFile("bun.lockb")).toBe(true) + expect(isLockFile("go.sum")).toBe(true) + expect(isLockFile("Cargo.lock")).toBe(true) + expect(isLockFile("poetry.lock")).toBe(true) + expect(isLockFile("pnpm-lock.yaml")).toBe(true) + }) + + test("does not flag normal files", () => { + expect(isLockFile("src/index.ts")).toBe(false) + expect(isLockFile("README.md")).toBe(false) + }) + }) + + // ── Integration tests using real git repos ──────────────────────── describe("lock file filtering", () => { - test("filters out package-lock.json from staged changes", async () => { - setGitOutput("diff --name-status --cached", "M\tsrc/index.ts\nM\tpackage-lock.json") - setGitOutput("diff --cached -- src/index.ts", "+console.log('hello')") - setGitOutput("diff --cached -- package-lock.json", "+lots of lock content") + test("filters out lock files from staged changes", async () => { + await using tmp = await tmpdir({ git: true }) + await stage(tmp.path, { + "src/index.ts": "console.log('hello')\n", + "package-lock.json": '{"lockfileVersion": 3}\n', + }) - const ctx = await getGitContext("/repo") + const ctx = await getGitContext(tmp.path) expect(ctx.files).toHaveLength(1) expect(ctx.files[0]!.path).toBe("src/index.ts") }) - test("filters out yarn.lock from staged changes", async () => { - setGitOutput("diff --name-status --cached", "M\tsrc/app.ts\nM\tyarn.lock") - setGitOutput("diff --cached -- src/app.ts", "+import x") - setGitOutput("diff --cached -- yarn.lock", "+lock data") - - const ctx = await getGitContext("/repo") - - expect(ctx.files).toHaveLength(1) - expect(ctx.files[0]!.path).toBe("src/app.ts") - }) - - test("filters out pnpm-lock.yaml from staged changes", async () => { - setGitOutput("diff --name-status --cached", "M\treadme.md\nM\tpnpm-lock.yaml") - setGitOutput("diff --cached -- pnpm-lock.yaml", "+lock") - setGitOutput("diff --cached -- readme.md", "+docs") - - const ctx = await getGitContext("/repo") - - expect(ctx.files).toHaveLength(1) - expect(ctx.files[0]!.path).toBe("readme.md") - }) - test("filters lock files in subdirectories", async () => { - setGitOutput("diff --name-status --cached", "M\tpackages/api/package-lock.json\nM\tpackages/api/src/index.ts") - setGitOutput("diff --cached -- packages/api/package-lock.json", "+lock stuff") - setGitOutput("diff --cached -- packages/api/src/index.ts", "+code") + await using tmp = await tmpdir({ git: true }) + await stage(tmp.path, { + "packages/api/package-lock.json": "lock\n", + "packages/api/src/index.ts": "export {}\n", + }) - const ctx = await getGitContext("/repo") + const ctx = await getGitContext(tmp.path) expect(ctx.files).toHaveLength(1) expect(ctx.files[0]!.path).toBe("packages/api/src/index.ts") }) - - test("filters out bun.lockb, go.sum, Cargo.lock, poetry.lock", async () => { - setGitOutput( - "diff --name-status --cached", - "M\tbun.lockb\nM\tgo.sum\nM\tCargo.lock\nM\tpoetry.lock\nM\tsrc/main.rs", - ) - setGitOutput("diff --cached -- bun.lockb", "binary") - setGitOutput("diff --cached -- go.sum", "+hash") - setGitOutput("diff --cached -- Cargo.lock", "+lock") - setGitOutput("diff --cached -- poetry.lock", "+lock") - setGitOutput("diff --cached -- src/main.rs", "+fn main() {}") - - const ctx = await getGitContext("/repo") - - expect(ctx.files).toHaveLength(1) - expect(ctx.files[0]!.path).toBe("src/main.rs") - }) }) describe("status parsing", () => { test("parses staged added files", async () => { - setGitOutput("diff --name-status --cached", "A\tsrc/new-file.ts") - setGitOutput("diff --cached -- src/new-file.ts", "+new content") + await using tmp = await tmpdir({ git: true }) + await stage(tmp.path, { "src/new-file.ts": "new content\n" }) - const ctx = await getGitContext("/repo") + const ctx = await getGitContext(tmp.path) expect(ctx.files).toHaveLength(1) expect(ctx.files[0]!.status).toBe("added") @@ -272,154 +196,58 @@ describe("commit-message.git-context", () => { }) test("parses staged modified files", async () => { - setGitOutput("diff --name-status --cached", "M\tsrc/existing.ts") - setGitOutput("diff --cached -- src/existing.ts", "+changed line") + await using tmp = await tmpdir({ git: true }) + // Create, commit, then modify + await stage(tmp.path, { "src/existing.ts": "original\n" }) + await $`git commit -m "add file"`.cwd(tmp.path).quiet() + await Bun.write(path.join(tmp.path, "src/existing.ts"), "changed\n") + await $`git add src/existing.ts`.cwd(tmp.path).quiet() - const ctx = await getGitContext("/repo") + const ctx = await getGitContext(tmp.path) expect(ctx.files).toHaveLength(1) expect(ctx.files[0]!.status).toBe("modified") }) test("parses staged deleted files", async () => { - setGitOutput("diff --name-status --cached", "D\tsrc/removed.ts") - setGitOutput("diff --cached -- src/removed.ts", "-deleted content") + await using tmp = await tmpdir({ git: true }) + await stage(tmp.path, { "src/removed.ts": "to delete\n" }) + await $`git commit -m "add file"`.cwd(tmp.path).quiet() + await $`git rm src/removed.ts`.cwd(tmp.path).quiet() - const ctx = await getGitContext("/repo") + const ctx = await getGitContext(tmp.path) expect(ctx.files).toHaveLength(1) expect(ctx.files[0]!.status).toBe("deleted") }) - - test("parses staged renamed files", async () => { - setGitOutput("diff --name-status --cached", "R100\told-name.ts\tnew-name.ts") - - const ctx = await getGitContext("/repo") - - expect(ctx.files).toHaveLength(1) - expect(ctx.files[0]!.status).toBe("renamed") - }) - - test("parses untracked files from porcelain", async () => { - setGitOutput("status --porcelain", "?? src/brand-new.ts") - - const ctx = await getGitContext("/repo") - - expect(ctx.files).toHaveLength(1) - expect(ctx.files[0]!.status).toBe("added") - expect(ctx.files[0]!.diff).toBe("New untracked file: src/brand-new.ts") - }) - - test("parses porcelain modified files", async () => { - // Use staged to avoid porcelain trim edge case - setGitOutput("diff --name-status --cached", "M\tsrc/changed.ts") - setGitOutput("diff --cached -- src/changed.ts", "+line") - - const ctx = await getGitContext("/repo") - - expect(ctx.files).toHaveLength(1) - expect(ctx.files[0]!.status).toBe("modified") - }) - - test("prefers staged changes over unstaged", async () => { - setGitOutput("diff --name-status --cached", "M\tsrc/staged.ts") - setGitOutput("diff --cached -- src/staged.ts", "+staged change") - // unstaged also exists but should be ignored when staged is present - setGitOutput("status --porcelain", " M src/unstaged.ts") - setGitOutput("diff -- src/unstaged.ts", "+unstaged change") - - const ctx = await getGitContext("/repo") - - expect(ctx.files).toHaveLength(1) - expect(ctx.files[0]!.path).toBe("src/staged.ts") - }) - - test("mapStatus returns 'modified' for unknown codes", async () => { - setGitOutput("diff --name-status --cached", "X\tsrc/weird.ts") - setGitOutput("diff --cached -- src/weird.ts", "+stuff") - - const ctx = await getGitContext("/repo") - - expect(ctx.files[0]!.status).toBe("modified") - }) }) describe("diff truncation", () => { - test("truncates diffs exceeding 4000 characters", async () => { - const longDiff = "x".repeat(5000) - setGitOutput("diff --name-status --cached", "M\tsrc/big.ts") - setGitOutput("diff --cached -- src/big.ts", longDiff) + test("truncates diffs exceeding max length", async () => { + await using tmp = await tmpdir({ git: true }) + const long = "x".repeat(MAX_DIFF_LENGTH + 2000) + await stage(tmp.path, { "src/big.ts": "original\n" }) + await $`git commit -m "add"`.cwd(tmp.path).quiet() + await Bun.write(path.join(tmp.path, "src/big.ts"), long + "\n") + await $`git add src/big.ts`.cwd(tmp.path).quiet() - const ctx = await getGitContext("/repo") + const ctx = await getGitContext(tmp.path) expect(ctx.files).toHaveLength(1) - expect(ctx.files[0]!.diff.length).toBeLessThan(5000) expect(ctx.files[0]!.diff).toContain("... [truncated]") - // 4000 chars + "\n... [truncated]" - expect(ctx.files[0]!.diff.length).toBe(4000 + "\n... [truncated]".length) - }) - - test("does not truncate diffs at exactly 4000 characters", async () => { - const exactDiff = "y".repeat(4000) - setGitOutput("diff --name-status --cached", "M\tsrc/exact.ts") - setGitOutput("diff --cached -- src/exact.ts", exactDiff) - - const ctx = await getGitContext("/repo") - - expect(ctx.files[0]!.diff).toBe(exactDiff) - expect(ctx.files[0]!.diff).not.toContain("... [truncated]") - }) - - test("does not truncate diffs under 4000 characters", async () => { - const shortDiff = "z".repeat(100) - setGitOutput("diff --name-status --cached", "M\tsrc/small.ts") - setGitOutput("diff --cached -- src/small.ts", shortDiff) - - const ctx = await getGitContext("/repo") - - expect(ctx.files[0]!.diff).toBe(shortDiff) - }) - }) - - describe("binary file detection", () => { - test("detects 'Binary files' in diff output", async () => { - setGitOutput("diff --name-status --cached", "M\tassets/logo.png") - setGitOutput("diff --cached -- assets/logo.png", "Binary files a/assets/logo.png and b/assets/logo.png differ") - - const ctx = await getGitContext("/repo") - - expect(ctx.files).toHaveLength(1) - expect(ctx.files[0]!.diff).toBe("Binary file assets/logo.png has been modified") - }) - - test("detects 'GIT binary patch' in diff output", async () => { - setGitOutput("diff --name-status --cached", "M\tassets/icon.ico") - setGitOutput("diff --cached -- assets/icon.ico", "GIT binary patch\nliteral 1234\ndata...") - - const ctx = await getGitContext("/repo") - - expect(ctx.files).toHaveLength(1) - expect(ctx.files[0]!.diff).toBe("Binary file assets/icon.ico has been modified") - }) - - test("does not flag normal diffs as binary", async () => { - setGitOutput("diff --name-status --cached", "M\tsrc/code.ts") - setGitOutput("diff --cached -- src/code.ts", "+const x = 1") - - const ctx = await getGitContext("/repo") - - expect(ctx.files[0]!.diff).toBe("+const x = 1") }) }) describe("selected files filtering", () => { test("only includes files in selectedFiles set", async () => { - setGitOutput("diff --name-status --cached", "M\tsrc/a.ts\nM\tsrc/b.ts\nM\tsrc/c.ts") - setGitOutput("diff --cached -- src/a.ts", "+a") - setGitOutput("diff --cached -- src/b.ts", "+b") - setGitOutput("diff --cached -- src/c.ts", "+c") + await using tmp = await tmpdir({ git: true }) + await stage(tmp.path, { + "src/a.ts": "a\n", + "src/b.ts": "b\n", + "src/c.ts": "c\n", + }) - const ctx = await getGitContext("/repo", ["src/a.ts", "src/c.ts"]) + const ctx = await getGitContext(tmp.path, ["src/a.ts", "src/c.ts"]) expect(ctx.files).toHaveLength(2) const paths = ctx.files.map((f) => f.path) @@ -429,20 +257,22 @@ describe("commit-message.git-context", () => { }) test("includes all files when selectedFiles is undefined", async () => { - setGitOutput("diff --name-status --cached", "M\tsrc/a.ts\nM\tsrc/b.ts") - setGitOutput("diff --cached -- src/a.ts", "+a") - setGitOutput("diff --cached -- src/b.ts", "+b") + await using tmp = await tmpdir({ git: true }) + await stage(tmp.path, { + "src/a.ts": "a\n", + "src/b.ts": "b\n", + }) - const ctx = await getGitContext("/repo") + const ctx = await getGitContext(tmp.path) expect(ctx.files).toHaveLength(2) }) test("returns empty files when selectedFiles has no matches", async () => { - setGitOutput("diff --name-status --cached", "M\tsrc/a.ts") - setGitOutput("diff --cached -- src/a.ts", "+a") + await using tmp = await tmpdir({ git: true }) + await stage(tmp.path, { "src/a.ts": "a\n" }) - const ctx = await getGitContext("/repo", ["src/nonexistent.ts"]) + const ctx = await getGitContext(tmp.path, ["src/nonexistent.ts"]) expect(ctx.files).toHaveLength(0) }) @@ -450,35 +280,24 @@ describe("commit-message.git-context", () => { describe("branch and recent commits", () => { test("returns current branch name", async () => { - setGitOutput("branch --show-current", "feature/my-branch") + await using tmp = await tmpdir({ git: true }) + await $`git checkout -b feature/my-branch`.cwd(tmp.path).quiet() - const ctx = await getGitContext("/repo") + const ctx = await getGitContext(tmp.path) expect(ctx.branch).toBe("feature/my-branch") }) - test("falls back to HEAD when branch is empty", async () => { - setGitOutput("branch --show-current", "") - - const ctx = await getGitContext("/repo") - - expect(ctx.branch).toBe("HEAD") - }) - test("returns recent commits as array", async () => { - setGitOutput("log --oneline -5", "abc1234 first\ndef5678 second\nghi9012 third") + await using tmp = await tmpdir({ git: true }) + // tmpdir already creates a root commit + await stage(tmp.path, { "a.ts": "a\n" }) + await $`git commit -m "second commit"`.cwd(tmp.path).quiet() - const ctx = await getGitContext("/repo") + const ctx = await getGitContext(tmp.path) - expect(ctx.recentCommits).toEqual(["abc1234 first", "def5678 second", "ghi9012 third"]) - }) - - test("returns empty array when no commits", async () => { - setGitOutput("log --oneline -5", "") - - const ctx = await getGitContext("/repo") - - expect(ctx.recentCommits).toEqual([]) + expect(ctx.recentCommits.length).toBeGreaterThanOrEqual(1) + expect(ctx.recentCommits.some((c) => c.includes("second commit"))).toBe(true) }) }) }) diff --git a/packages/opencode/test/kilocode/config-gitignore.test.ts b/packages/opencode/test/kilocode/config-gitignore.test.ts new file mode 100644 index 00000000000..2c64bfb8f12 --- /dev/null +++ b/packages/opencode/test/kilocode/config-gitignore.test.ts @@ -0,0 +1,42 @@ +// kilocode_change - new file +// +// Kilo uses @npmcli/arborist instead of bun for dependency installation. +// Users may have pnpm or yarn as their system package manager, which can +// produce lockfiles in the .kilo/ config directory. These must be ignored +// so they don't appear as untracked files in the user's project. + +import { expect, spyOn, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Config } from "../../src/config/config" +import { Npm } from "../../src/npm" +import * as Network from "../../src/util/network" +import { Filesystem } from "../../src/util/filesystem" +import { AppRuntime } from "../../src/effect/app-runtime" +import { tmpdir } from "../fixture/fixture" + +test(".gitignore includes pnpm and yarn lockfile patterns", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "a") + await fs.mkdir(dir, { recursive: true }) + + const online = spyOn(Network, "online").mockReturnValue(false) + const run = spyOn(Npm, "install").mockImplementation(async (d: string) => { + const mod = path.join(d, "node_modules", "@kilocode", "plugin") + await fs.mkdir(mod, { recursive: true }) + await Filesystem.write( + path.join(mod, "package.json"), + JSON.stringify({ name: "@kilocode/plugin", version: "1.0.0" }), + ) + }) + + try { + await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir))) + const ignore = await Filesystem.readText(path.join(dir, ".gitignore")) + expect(ignore).toContain("pnpm-lock.yaml") + expect(ignore).toContain("yarn.lock") + } finally { + online.mockRestore() + run.mockRestore() + } +}) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts new file mode 100644 index 00000000000..39969b645dc --- /dev/null +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -0,0 +1,323 @@ +import path from "path" +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { KiloSessionPromptQueue } from "../../src/kilocode/session/prompt-queue" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageID, SessionID } from "../../src/session/schema" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +function line(input: unknown) { + return `data: ${JSON.stringify(input)}\n\n` +} + +function chunk(input: { delta?: Record; finish?: string }) { + return { + id: "chatcmpl-queue-test", + object: "chat.completion.chunk", + choices: [ + { + delta: input.delta ?? {}, + ...(input.finish ? { finish_reason: input.finish } : {}), + }, + ], + } +} + +function reply(input: { text: string; ready?: () => void; wait?: Promise }) { + const enc = new TextEncoder() + const head = line(chunk({ delta: { role: "assistant" } })) + const tail = [ + line(chunk({ delta: { content: input.text } })), + line(chunk({ finish: "stop" })), + "data: [DONE]\n\n", + ].join("") + + return new ReadableStream({ + start(ctrl) { + ctrl.enqueue(enc.encode(head)) + input.ready?.() + const done = () => { + ctrl.enqueue(enc.encode(tail)) + ctrl.close() + } + if (input.wait) { + void input.wait.then(done) + return + } + done() + }, + }) +} + +function hasText(msg: Awaited>, text: string) { + return msg.parts.some((part) => part.type === "text" && part.text.includes(text)) +} + +function user(sessionID: SessionID, id: MessageID): MessageV2.WithParts { + return { + info: { + id, + sessionID, + role: "user", + time: { created: 1 }, + agent: "code", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("model") }, + }, + parts: [], + } +} + +function assistant(sessionID: SessionID, id: MessageID, parentID: MessageID): MessageV2.WithParts { + return { + info: { + id, + sessionID, + role: "assistant", + time: { created: 1, completed: 2 }, + parentID, + modelID: ModelID.make("model"), + providerID: ProviderID.make("test"), + mode: "code", + agent: "code", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + }, + parts: [], + } +} + +describe("session prompt queue", () => { + test("scopes queued turns without moving prior assistant history", async () => { + const sessionID = SessionID.make("session_scope") + const one = MessageID.make("message_01") + const ans = MessageID.make("message_02") + const two = MessageID.make("message_03") + const three = MessageID.make("message_04") + const messages = [ + user(sessionID, one), + assistant(sessionID, ans, one), + user(sessionID, two), + user(sessionID, three), + ] + + const ids = await Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + two, + Effect.sync(() => KiloSessionPromptQueue.scope(sessionID, messages).map((item) => item.info.id)), + Effect.succeed([]), + ), + ) + + expect(ids).toEqual([one, ans, two]) + }) + + test("continues a queued prompt after the active run finishes", async () => { + const ready = Promise.withResolvers() + const release = Promise.withResolvers() + const calls: number[] = [] + const replies = ["first reply", "second reply", "third reply"] + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + + calls.push(Date.now()) + const body = + calls.length === 1 + ? reply({ text: replies[0], ready: ready.resolve, wait: release.promise }) + : reply({ text: replies[calls.length - 1] ?? "extra reply" }) + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + }, + }) + + try { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["alibaba"], + provider: { + alibaba: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + agent: { + code: { + model: "alibaba/qwen-plus", + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "Queued prompt regression" }) + const first = SessionPrompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "first prompt" }], + }) + + await ready.promise + + const second = SessionPrompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "second prompt" }], + }) + const third = SessionPrompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "third prompt" }], + }) + + await Bun.sleep(20) + expect(calls).toHaveLength(1) + const queued = await Session.messages({ sessionID: session.id }) + expect(queued.filter((msg) => msg.info.role === "user")).toHaveLength(3) + expect(queued.filter((msg) => msg.info.role === "assistant")).toHaveLength(1) + + release.resolve() + await first + const two = await second + const three = await third + + expect(hasText(two, "second reply")).toBe(true) + expect(hasText(three, "third reply")).toBe(true) + expect(calls).toHaveLength(3) + + const msgs = await Session.messages({ sessionID: session.id }) + const users = msgs.filter((msg) => msg.info.role === "user") + const assistants = msgs.filter((msg) => msg.info.role === "assistant") + const text = assistants.flatMap((msg) => + msg.parts.filter((part) => part.type === "text").map((part) => part.text), + ) + expect(users).toHaveLength(3) + expect(assistants).toHaveLength(3) + expect(text).toContain("first reply") + expect(text).toContain("second reply") + expect(text).toContain("third reply") + for (const [index, item] of assistants.entries()) { + const user = users[index]?.info + if (item.info.role !== "assistant" || user?.role !== "user") throw new Error("missing turn") + expect(item.info.parentID).toBe(user.id) + } + }, + }) + } finally { + server.stop(true) + } + }) + + test("cancel drops queued prompts and resets internal state", async () => { + const ready = Promise.withResolvers() + const calls: number[] = [] + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + + calls.push(Date.now()) + const body = reply({ text: "first reply", ready: ready.resolve, wait: new Promise(() => {}) }) + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + }, + }) + + try { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["alibaba"], + provider: { + alibaba: { + options: { apiKey: "test-key", baseURL: `${server.url.origin}/v1` }, + }, + }, + agent: { code: { model: "alibaba/qwen-plus" } }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "Queued cancel regression" }) + const first = SessionPrompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "first prompt" }], + }) + await ready.promise + + const second = SessionPrompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "second prompt" }], + }) + const third = SessionPrompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "third prompt" }], + }) + + await Bun.sleep(20) + expect(calls).toHaveLength(1) + + await SessionPrompt.cancel(session.id) + await Promise.all([first, second, third]) + + expect(calls).toHaveLength(1) + const msgs = await Session.messages({ sessionID: session.id }) + const assistants = msgs.filter((msg) => msg.info.role === "assistant") + expect(assistants).toHaveLength(1) + expect(msgs.filter((msg) => msg.info.role === "user")).toHaveLength(3) + + // Internal state should have no lingering tail/version/target entries after the last release. + const ids = await Effect.runPromise( + KiloSessionPromptQueue.enqueue( + session.id, + MessageID.make("message_probe"), + Effect.succeed(KiloSessionPromptQueue.scope(session.id, []).map((item) => item.info.id)), + Effect.succeed([]), + ), + ) + expect(ids).toEqual([]) + }, + }) + } finally { + server.stop(true) + } + }) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 3a6856d3036..c3ab9eed0e0 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.2.10", + "version": "7.2.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index 2ca790c18f4..fb47dee2f52 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.2.10", + "version": "7.2.11", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 06ada383181..887543c5c53 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.2.10", + "version": "7.2.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 058bf97582e..b11cd8a69d3 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -67,6 +67,8 @@ import type { KiloCloudSessionImportResponses, KiloCloudSessionsErrors, KiloCloudSessionsResponses, + KilocodeHeapSnapshotErrors, + KilocodeHeapSnapshotResponses, KilocodeRemoveAgentErrors, KilocodeRemoveAgentResponses, KilocodeRemoveSkillErrors, @@ -5176,6 +5178,42 @@ export class SessionImport extends HeyApiClient { } } +export class Heap extends HeyApiClient { + /** + * Write heap snapshot + * + * Write a heap snapshot for the CLI process to the log directory. + */ + public snapshot( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeHeapSnapshotResponses, + KilocodeHeapSnapshotErrors, + ThrowOnError + >({ + url: "/kilocode/heap/snapshot", + ...options, + ...params, + }) + } +} + export class Kilocode extends HeyApiClient { /** * Remove a skill @@ -5259,6 +5297,11 @@ export class Kilocode extends HeyApiClient { get sessionImport(): SessionImport { return (this._sessionImport ??= new SessionImport({ client: this.client })) } + + private _heap?: Heap + get heap(): Heap { + return (this._heap ??= new Heap({ client: this.client })) + } } export class Organization extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 17232b62485..370f42c431d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -6322,6 +6322,34 @@ export type KilocodeSessionImportPartResponses = { export type KilocodeSessionImportPartResponse = KilocodeSessionImportPartResponses[keyof KilocodeSessionImportPartResponses] +export type KilocodeHeapSnapshotData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/heap/snapshot" +} + +export type KilocodeHeapSnapshotErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type KilocodeHeapSnapshotError = KilocodeHeapSnapshotErrors[keyof KilocodeHeapSnapshotErrors] + +export type KilocodeHeapSnapshotResponses = { + /** + * Heap snapshot file path + */ + 200: string +} + +export type KilocodeHeapSnapshotResponse = KilocodeHeapSnapshotResponses[keyof KilocodeHeapSnapshotResponses] + export type KilocodeRemoveSkillData = { body?: { location: string diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 848df38d3f8..44178bbf8c0 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.2.10", + "version": "7.2.11", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 7023c3bd4b4..9bf3e48bb01 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.2.10", + "version": "7.2.11", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 42c3764bdd4..0450e757afb 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -147,7 +147,7 @@ export const dict: Record = { "ui.message.copy": "Copy", "ui.message.copyMessage": "Copy message", "ui.message.forkMessage": "Fork to new session", - "ui.message.revertMessage": "Revert message", + "ui.message.revertMessage": "Revert to here", "ui.message.copyResponse": "Copy response", "ui.message.copied": "Copied", "ui.message.duration.seconds": "{{count}}s", diff --git a/packages/ui/src/i18n/nl.ts b/packages/ui/src/i18n/nl.ts index 11f81c83c8c..b86fa54ea11 100644 --- a/packages/ui/src/i18n/nl.ts +++ b/packages/ui/src/i18n/nl.ts @@ -150,7 +150,7 @@ export const dict: Record = { "ui.message.copyResponse": "Antwoord kopiëren", "ui.message.copied": "Gekopieerd", "ui.message.forkMessage": "Fork to new session", - "ui.message.revertMessage": "Revert message", + "ui.message.revertMessage": "Hiernaar terugzetten", "ui.message.revert": "Hiernaar terugdraaien", "ui.message.interrupted": "Onderbroken", "ui.message.queued": "In wachtrij", diff --git a/packages/ui/src/i18n/uk.ts b/packages/ui/src/i18n/uk.ts index 2e92d49ee2a..090a0481874 100644 --- a/packages/ui/src/i18n/uk.ts +++ b/packages/ui/src/i18n/uk.ts @@ -155,7 +155,7 @@ export const dict = { "ui.message.copyResponse": "Копіювати відповідь", "ui.message.copied": "Скопійовано", "ui.message.forkMessage": "Fork to new session", - "ui.message.revertMessage": "Revert message", + "ui.message.revertMessage": "Повернутися сюди", "ui.message.revert": "Повернутися до цього місця", "ui.message.interrupted": "Перервано", "ui.message.queued": "В черзі", diff --git a/packages/util/package.json b/packages/util/package.json index 938ad1acd2c..7d7714920b1 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "7.2.10", + "version": "7.2.11", "private": true, "type": "module", "license": "MIT", diff --git a/script/publish.ts b/script/publish.ts index 846321e95e5..088ecc118d2 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -34,6 +34,38 @@ Add highlights before publishing. Delete this section if no highlights. console.log("=== publishing ===\n") +// kilocode_change start - consume changesets on the publish runner so changelog +// changes are included in the release commit. Previously this ran in the +// version job on a separate runner whose workspace was discarded. +{ + await $`bun install` + const paths = ["packages/kilo-vscode/CHANGELOG.md", "packages/opencode/CHANGELOG.md"] + const before = new Map() + for (const p of paths) { + before.set( + p, + await Bun.file(p) + .text() + .catch(() => ""), + ) + } + const res = await $`bunx changeset version`.nothrow() + if (res.exitCode !== 0) { + console.warn("changeset version failed (exit " + res.exitCode + ")") + } + // Changeset computes its own version from package.json, but we use + // Script.version. Fix the heading in any changelog that was modified. + for (const p of paths) { + const content = await Bun.file(p) + .text() + .catch(() => "") + if (content !== before.get(p)) { + await Bun.write(p, content.replace(/^## .+$/m, `## ${Script.version}`)) + } + } +} +// kilocode_change end + const pkgjsons = await Array.fromAsync( new Bun.Glob("**/package.json").scan({ absolute: true, @@ -73,7 +105,16 @@ if (Script.release) { // kilocode_change end // kilocode_change start - mark prerelease GitHub releases accordingly + // and populate release notes from the changelog updated by changeset above const flags = Script.preview ? ["--draft=false", "--prerelease"] : ["--draft=false"] + const changelog = await Bun.file("packages/kilo-vscode/CHANGELOG.md") + .text() + .catch(() => "") + const body = extractLatestSection(changelog) || "No notable changes" + const tmp = process.env.RUNNER_TEMP ?? "/tmp" + const notes = `${tmp}/release-notes.txt` + await Bun.write(notes, body) + flags.push("--notes-file", notes) await $`gh release edit v${Script.version} ${flags} --repo ${process.env.GH_REPO}` // kilocode_change end } @@ -94,3 +135,17 @@ await import(`../packages/kilo-vscode/script/publish.ts`) const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) + +// kilocode_change start - extract latest changelog section for release notes +function extractLatestSection(changelog: string): string { + if (!changelog) return "" + const lines = changelog.split("\n") + const start = lines.findIndex((line) => /^## /.test(line)) + if (start < 0) return "" + const end = lines.findIndex((line, i) => i > start && /^## /.test(line)) + return lines + .slice(start + 1, end < 0 ? undefined : end) + .join("\n") + .trim() +} +// kilocode_change end diff --git a/script/upstream/package.json b/script/upstream/package.json index bfa16f043b3..b054207b7d6 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.2.10", + "version": "7.2.11", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/script/upstream/transforms/transform-package-json.ts b/script/upstream/transforms/transform-package-json.ts index ce30a3f11dd..685cecf0ebd 100644 --- a/script/upstream/transforms/transform-package-json.ts +++ b/script/upstream/transforms/transform-package-json.ts @@ -383,6 +383,26 @@ export async function transformPackageJson(file: string, options: PackageJsonOpt changes.push(`scripts.changeset:version: preserved Kilo's changeset:version script`) } + // Preserve Kilo's test runner scripts for packages/opencode + if ( + relativePath === "packages/opencode/package.json" && + ourScripts?.test && + pkg.scripts?.test !== ourScripts.test + ) { + pkg.scripts = pkg.scripts || {} + pkg.scripts.test = ourScripts.test + changes.push(`scripts.test: preserved Kilo's test runner script`) + } + if ( + relativePath === "packages/opencode/package.json" && + ourScripts?.["test:ci"] && + pkg.scripts?.["test:ci"] !== ourScripts["test:ci"] + ) { + pkg.scripts = pkg.scripts || {} + pkg.scripts["test:ci"] = ourScripts["test:ci"] + changes.push(`scripts.test:ci: preserved Kilo's CI test runner script`) + } + // Merge catalog with "newest wins" strategy if (ourWorkspaces?.catalog || theirWorkspaces?.catalog) { pkg.workspaces = pkg.workspaces || {} @@ -608,6 +628,22 @@ export async function transformAllPackageJson(options: PackageJsonOptions = {}): changes.push(`scripts.extension: preserved Kilo's extension script`) } + // Preserve Kilo's test runner scripts for packages/opencode + if (path === "packages/opencode/package.json" && kiloScripts?.test && pkg.scripts?.test !== kiloScripts.test) { + pkg.scripts = pkg.scripts || {} + pkg.scripts.test = kiloScripts.test + changes.push(`scripts.test: preserved Kilo's test runner script`) + } + if ( + path === "packages/opencode/package.json" && + kiloScripts?.["test:ci"] && + pkg.scripts?.["test:ci"] !== kiloScripts["test:ci"] + ) { + pkg.scripts = pkg.scripts || {} + pkg.scripts["test:ci"] = kiloScripts["test:ci"] + changes.push(`scripts.test:ci: preserved Kilo's CI test runner script`) + } + // Merge catalog with "newest wins" strategy if (kiloWorkspaces?.catalog || upstreamWorkspaces?.catalog) { pkg.workspaces = pkg.workspaces || {} diff --git a/script/version.ts b/script/version.ts index a34c5bc14ff..479fc557b98 100755 --- a/script/version.ts +++ b/script/version.ts @@ -6,30 +6,10 @@ import { $ } from "bun" const output = [`version=${Script.version}`] if (!Script.preview) { - // kilocode_change start - use changesets for changelog generation - // Run changeset version to consume .changeset/*.md files into CHANGELOG.md. - // This also bumps package.json versions, but publish.ts overwrites them with - // Script.version later, so the changeset-computed versions are irrelevant. - // If no changesets exist yet, changeset version exits 0 but writes nothing. - const result = await $`bunx changeset version`.nothrow() - if (result.exitCode !== 0) { - console.warn("changeset version failed (exit " + result.exitCode + "), continuing with fallback notes") - } - - // Extract the latest version section from the kilo-code extension changelog. - // Changesets writes to packages/kilo-vscode/CHANGELOG.md (not root) because - // the changeset targets the "kilo-code" package. This is also the file the - // VS Code Marketplace reads at publish time. - const changelog = await Bun.file(`${process.cwd()}/packages/kilo-vscode/CHANGELOG.md`) - .text() - .catch(() => "") - const body = extractLatestSection(changelog) || "No notable changes" - - const dir = process.env.RUNNER_TEMP ?? "/tmp" - const notesFile = `${dir}/opencode-release-notes.txt` - await Bun.write(notesFile, body) + // kilocode_change start - create draft release; changelog generation and + // release notes are handled by publish.ts on the same runner that commits. + await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes ""` // kilocode_change end - await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes-file ${notesFile}` const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json() output.push(`release=${release.databaseId}`) output.push(`tag=${release.tagName}`) @@ -49,18 +29,4 @@ if (process.env.GITHUB_OUTPUT) { await Bun.write(process.env.GITHUB_OUTPUT, output.join("\n")) } -// kilocode_change start - extract latest changelog section for release notes -function extractLatestSection(changelog: string): string { - if (!changelog) return "" - const lines = changelog.split("\n") - // Find first ## heading (version section) - const start = lines.findIndex((line) => /^## /.test(line)) - if (start < 0) return "" - // Find the next ## heading after the first one - const end = lines.findIndex((line, i) => i > start && /^## /.test(line)) - const section = lines.slice(start + 1, end < 0 ? undefined : end) - return section.join("\n").trim() -} -// kilocode_change end - process.exit(0) diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index f0b0b85deb7..681539895a5 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.2.10", + "version": "7.2.11", "publisher": "sst-dev", "repository": { "type": "git",