mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Merge branch 'main' into imanolmaiztegui/kilo-opencode-v1.14.17
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Open xterm.js-powered terminal tabs in the Agent Manager. Click the chevron next to the `+` tab button and pick "New Terminal" (or press `Cmd+Shift+T` / `Ctrl+Shift+T`) to spawn a real shell in the selected worktree or Local directory. Terminals render as proper tabs alongside agent sessions, support mixed drag-reorder with session tabs, and persist their position across webview reloads. The existing VS Code integrated terminal shortcut (`Cmd+/`) is unchanged.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
CLI suggestions now render inline in the conversation at the position of the suggest tool call, instead of as a separate bar above the prompt input. The inline bar renders as a single full-width row with a subtle background and clickable action buttons, matching the VS Code extension. Dismissal happens automatically when you send a new prompt. Blocking suggestions still use the above-prompt overlay.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
CLI suggestions now render above an active input prompt. You can keep typing and submit a new message while a suggestion is on screen — sending a message auto-dismisses the pending suggestion, matching the VS Code extension behavior. The redundant "Dismiss" row has been removed; click an option to accept, or press Esc to dismiss.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix an infinite "busy" loop that could occur when a model kept reporting context overflow after every compaction. Each turn now caps compactions at three attempts and closes the turn with a visible context-overflow error instead of silently looping forever.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add the initial JetBrains session chat UI and improve sandbox debug logging for tracing chat events across frontend and backend.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Restore explicit Submit behavior for single-choice question prompts in the VS Code extension so option clicks stay visible for review instead of immediately sending the answer.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix TUI freeze on huge-file diffs. Session-summary and file-view patches now use git directly instead of a JavaScript Myers implementation, so files of any size render a full diff without blocking the session.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@opencode-ai/ui": patch
|
||||
"@kilocode/kilo-ui": patch
|
||||
---
|
||||
|
||||
Significantly speed up LLM token streaming in long sessions. The chat view now stays responsive while the model streams a reply, even in sessions with hundreds of messages. Previously, each SSE batch produced ~1.3 seconds of visible freeze (roughly 80 dropped frames); streaming ticks are now inside a single animation frame.
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
@@ -88,7 +88,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -121,7 +121,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -172,7 +172,7 @@
|
||||
},
|
||||
"packages/kilo-docs": {
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"dependencies": {
|
||||
"@docsearch/css": "^4",
|
||||
"@docsearch/js": "^4",
|
||||
@@ -201,7 +201,7 @@
|
||||
},
|
||||
"packages/kilo-gateway": {
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
@@ -237,7 +237,7 @@
|
||||
},
|
||||
"packages/kilo-i18n": {
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -250,7 +250,7 @@
|
||||
},
|
||||
"packages/kilo-telemetry": {
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
@@ -270,7 +270,7 @@
|
||||
},
|
||||
"packages/kilo-ui": {
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/shared": "workspace:*",
|
||||
@@ -305,7 +305,7 @@
|
||||
},
|
||||
"packages/kilo-vscode": {
|
||||
"name": "kilo-code",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
@@ -365,7 +365,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "@kilocode/cli",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"bin": {
|
||||
"kilo": "./bin/kilo",
|
||||
"kilocode": "./bin/kilo",
|
||||
@@ -449,8 +449,10 @@
|
||||
"gray-matter": "4.0.3",
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "catalog:",
|
||||
"iconv-lite": "0.7.2",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jschardet": "3.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
@@ -518,7 +520,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@kilocode/plugin",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -543,7 +545,7 @@
|
||||
},
|
||||
"packages/script": {
|
||||
"name": "@opencode-ai/script",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"dependencies": {
|
||||
"semver": "^7.6.3",
|
||||
},
|
||||
@@ -554,7 +556,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@kilocode/sdk",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -569,7 +571,7 @@
|
||||
},
|
||||
"packages/shared": {
|
||||
"name": "@opencode-ai/shared",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -593,7 +595,7 @@
|
||||
},
|
||||
"packages/storybook": {
|
||||
"name": "@opencode-ai/storybook",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
@@ -616,7 +618,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -3588,6 +3590,8 @@
|
||||
|
||||
"jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="],
|
||||
|
||||
"jschardet": ["jschardet@3.1.4", "", {}, "sha512-/kmVISmrwVwtyYU40iQUOp3SUPk2dhNCMsZBQX0R1/jZ8maaXJ/oZIzUOiyOqcgtLnETFKYChbJ5iDC/eWmFHg=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-y/4I69G/gb0ZyPRdZx7+h/pTyv9TWZIhM1w9ZJ7Ac48=",
|
||||
"aarch64-linux": "sha256-RmFPbU09RDOER+KgmknLpKsUO008JeMRRlCK4XkAXwo=",
|
||||
"aarch64-darwin": "sha256-WtA8nnntNaOI8RpQYoGyItofNJBiF3f58r++iggkrUg=",
|
||||
"x86_64-darwin": "sha256-WSg/P8mAkc0nH59LYer/56xKFuZz/8sXr+6HoZEH/pc="
|
||||
"x86_64-linux": "sha256-PnZh7bJ97lE8BsAiICc653OV468GjehAs9lXV11DKXw=",
|
||||
"aarch64-linux": "sha256-VMcH747sR0wDCOXXDbnHQQid9WeigWr/HtRrj2yHxuU=",
|
||||
"aarch64-darwin": "sha256-lsNiZcz7NjGmuls22z2VCbXwsr1h3Q0DlnI46r5RxeA=",
|
||||
"x86_64-darwin": "sha256-z4yto3ZjuXpKP5vP5ppl2oX7T0EleSQAByyMqPO0uiE="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -145,6 +145,6 @@
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch"
|
||||
},
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -91,6 +91,7 @@ export const CodeWithAiNav: NavSection[] = [
|
||||
children: "Task Todo List",
|
||||
},
|
||||
{ href: "/code-with-ai/features/checkpoints", children: "Checkpoints" },
|
||||
{ href: "/code-with-ai/features/file-encoding", children: "File Encoding" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack --port 3002",
|
||||
|
||||
@@ -53,13 +53,25 @@ You'll find these settings in the Kilo Code settings panel (click the {% codicon
|
||||
- **Display name** — A human-readable name shown in the UI.
|
||||
- **Base URL** — The provider's OpenAI-compatible API endpoint (e.g., `https://api.your-provider.com/v1`). Kilo auto-fetches available models when a valid URL is entered.
|
||||
- **API key** — Your API key. Optional — leave empty if authentication is handled via headers.
|
||||
- **Models** — Add models manually or select from the auto-fetched list.
|
||||
- **Models** — Add models manually or select from the auto-fetched list (see [Automatic Model Detection](#automatic-model-detection) below).
|
||||
- **Headers** (optional) — Custom HTTP headers as key-value pairs.
|
||||
|
||||
4. Click **Submit** to save. The provider's models appear in the model picker.
|
||||
|
||||
For additional model configuration (token limits, tool calling, variants), edit the `kilo.jsonc` config file directly — see the **CLI** tab or the [Custom Models](/docs/code-with-ai/agents/custom-models) guide.
|
||||
|
||||
### Automatic Model Detection
|
||||
|
||||
When configuring a custom OpenAI-compatible provider, Kilo Code can automatically detect available models from your provider's `/v1/models` endpoint.
|
||||
|
||||
Once you enter a valid **Base URL** and **API Key**, Kilo Code will query the provider and present a searchable model picker with all available models. You can:
|
||||
|
||||
- **Search** with fuzzy matching (e.g., typing "gpt4o" finds "gpt-4o-mini")
|
||||
- **Select** individual models to add to the provider configuration
|
||||
- **Edit** an existing custom provider to add or remove models later
|
||||
|
||||
This eliminates the need to manually look up and type model IDs. If auto-detection fails (for example, if the provider doesn't support the `/v1/models` endpoint), you can still enter model IDs manually.
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="CLI" %}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: "File Encoding"
|
||||
description: "How Kilo handles text file encodings when reading and editing files"
|
||||
---
|
||||
|
||||
# File Encoding
|
||||
|
||||
Kilo automatically detects the text encoding of each file it reads and preserves that encoding when writing changes back. You can work with source files in any supported encoding without worrying about Kilo corrupting them or showing the model garbled text.
|
||||
|
||||
## Supported Encodings
|
||||
|
||||
- UTF-8, with or without BOM
|
||||
- UTF-16 LE and UTF-16 BE, **with a BOM**
|
||||
- Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR
|
||||
- Windows-1251, KOI8-R
|
||||
- The ISO-8859 family
|
||||
- Other common legacy Latin and CJK encodings detected by [jschardet](https://github.com/aadsm/jschardet) and decoded by [iconv-lite](https://github.com/ashtuchkin/iconv-lite)
|
||||
|
||||
New files Kilo creates are always UTF-8 without a BOM. Encoding detection only runs when Kilo reads or overwrites an existing file.
|
||||
|
||||
## Not Supported
|
||||
|
||||
- **UTF-16 without a BOM.** The byte pattern is ambiguous and cannot be distinguished reliably from other encodings. Save the file with a BOM or convert it to UTF-8.
|
||||
- **UTF-32.** Extremely rare in practice; convert to UTF-8 if you need Kilo to work with it.
|
||||
|
||||
{% callout type="info" %}
|
||||
Encoding detection is statistical. Very short files, or files whose byte patterns happen to look like a different encoding, may occasionally be misidentified. If that happens, converting the file to UTF-8 is the most reliable workaround.
|
||||
{% /callout %}
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
If Kilo displays a file as garbled text, or writes it back in a different encoding than it was saved in, please open an issue at [github.com/Kilo-Org/kilocode/issues](https://github.com/Kilo-Org/kilocode/issues) and include all of the following:
|
||||
|
||||
- **A file that reproduces the issue.** Attach the actual file to the issue — do not paste its contents into the issue body, since the web form will re-encode the text.
|
||||
- **The exact name of the encoding** the file is saved in, for example `Shift_JIS`, `windows-1251`, or `UTF-16 LE with BOM`.
|
||||
- **A SHA-256 hash of the attached file** so we can confirm it wasn't corrupted when uploaded.
|
||||
|
||||
On macOS or Linux:
|
||||
|
||||
```bash
|
||||
shasum -a 256 path/to/file
|
||||
```
|
||||
|
||||
On Windows:
|
||||
|
||||
```powershell
|
||||
Get-FileHash path\to\file -Algorithm SHA256
|
||||
```
|
||||
|
||||
- **The model and provider** you were using when the issue occurred, for example `claude-sonnet-4.5` via Kilo Gateway.
|
||||
- **The exact Kilo version** you are running. For the CLI, run `kilo --version`. For the VS Code extension, open the Extensions view and check the version next to "Kilo Code".
|
||||
@@ -47,7 +47,12 @@ If you check config files into version control, make sure they do not contain AP
|
||||
|
||||
### Export and Import
|
||||
|
||||
Config files are plain-text and portable — copy them between machines and you're done.
|
||||
You can export and import settings from the **About Kilo Code** tab in the Settings UI:
|
||||
|
||||
- **Export**: Saves your global config as a `kilo-settings.json` file. Review it before sharing, because config values are exported as-is.
|
||||
- **Import**: Loads a previously exported JSON file into the settings draft. Changes are not applied immediately — you can review them and click Save or Discard, just like any manual edit.
|
||||
|
||||
Config files are also plain-text and portable — you can copy `~/.config/kilo/kilo.jsonc` between machines directly.
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="CLI" %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Kilo-specific i18n translations and overrides",
|
||||
|
||||
+25
-3
@@ -55,6 +55,9 @@ class SessionController(
|
||||
private val workspace: Workspace,
|
||||
private val app: KiloAppService,
|
||||
private val cs: CoroutineScope,
|
||||
comp: java.awt.Component? = null,
|
||||
private val flushMs: Long = EVENT_FLUSH_MS,
|
||||
private val condense: Boolean = true,
|
||||
) : Disposable {
|
||||
|
||||
companion object {
|
||||
@@ -70,6 +73,7 @@ class SessionController(
|
||||
private val listeners = mutableListOf<SessionControllerListener>()
|
||||
private var sessionId: String? = id
|
||||
private val directory: String get() = workspace.directory
|
||||
private val updates = SessionUpdateQueue(parent, comp, flushMs, ::handle, condense, id != null) { sessionId ?: "pending" }
|
||||
|
||||
private var partType: String? = null
|
||||
private var tool: String? = null
|
||||
@@ -82,6 +86,8 @@ class SessionController(
|
||||
Disposer.register(parent) { listeners.remove(listener) }
|
||||
}
|
||||
|
||||
internal fun flushEvents() = updates.requestFlush(true)
|
||||
|
||||
fun prompt(text: String) {
|
||||
val sid = sessionId ?: "pending"
|
||||
LOG.debug { "${ChatLogSummary.sid(sid)} ${ChatLogSummary.prompt(text)} ${ChatLogSummary.dir(directory)}" }
|
||||
@@ -247,13 +253,16 @@ class SessionController(
|
||||
try {
|
||||
val history = sessions.messages(id, directory)
|
||||
LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(history)}" }
|
||||
edt {
|
||||
runEdt {
|
||||
this@SessionController.model.loadHistory(history)
|
||||
if (!model.isEmpty()) showMessages()
|
||||
}
|
||||
recoverPending(id)
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("${ChatLogSummary.sid(id)} kind=history dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
|
||||
} finally {
|
||||
updates.holdFlush(false)
|
||||
updates.requestFlush(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,7 +279,7 @@ class SessionController(
|
||||
return@collect
|
||||
}
|
||||
LOG.debug { "${ChatLogSummary.sid(id)} pass=true ${ChatLogSummary.eventBody(event)}" }
|
||||
edt { handle(event) }
|
||||
updates.enqueue(event)
|
||||
}
|
||||
} finally {
|
||||
LOG.debug { "${ChatLogSummary.sid(id)} kind=subscription subscribe=false" }
|
||||
@@ -293,7 +302,7 @@ class SessionController(
|
||||
LOG.debug {
|
||||
"${ChatLogSummary.sid(id)} kind=recovery permissions=${permissions.size} questions=${questions.size} status=${status?.type ?: "none"} branch=$branch"
|
||||
}
|
||||
edt {
|
||||
runEdt {
|
||||
if (permissions.isNotEmpty()) {
|
||||
model.setState(SessionState.AwaitingPermission(toPermission(permissions.last())))
|
||||
} else if (questions.isNotEmpty()) {
|
||||
@@ -459,6 +468,10 @@ class SessionController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handle(events: List<ChatEventDto>) {
|
||||
for (event in events) handle(event)
|
||||
}
|
||||
|
||||
private fun showMessages() {
|
||||
if (!model.showMessages) {
|
||||
model.showMessages = true
|
||||
@@ -500,6 +513,15 @@ class SessionController(
|
||||
ApplicationManager.getApplication().invokeLater(block)
|
||||
}
|
||||
|
||||
private fun runEdt(block: () -> Unit) {
|
||||
val application = ApplicationManager.getApplication()
|
||||
if (application.isDispatchThread) {
|
||||
block()
|
||||
return
|
||||
}
|
||||
application.invokeAndWait(block)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
eventJob?.cancel()
|
||||
cs.cancel()
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package ai.kilocode.client.session
|
||||
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
|
||||
/**
|
||||
* Reduces a batch of queued [ChatEventDto] events before they are flushed to
|
||||
* the model, by merging consecutive same-key snapshot and text-delta events.
|
||||
*
|
||||
* ## Algorithm
|
||||
*
|
||||
* Events are scanned in arrival order. Three ordered accumulators hold
|
||||
* mergeable events keyed by their identity. Any non-mergeable event acts as a
|
||||
* **barrier** — all accumulated events are flushed into the output before the
|
||||
* barrier event is appended. This preserves the original ordering while
|
||||
* collapsing N updates into one per key per batch.
|
||||
*
|
||||
* ## What is merged
|
||||
*
|
||||
* - `ChatEventDto.PartDelta` where `field == "text"` and same
|
||||
* `(sessionId, messageId, partId, field)` key. Text is concatenated.
|
||||
* - `ChatEventDto.PartUpdated` for the same `(sessionId, messageId, partId)`.
|
||||
* Latest snapshot wins.
|
||||
* - `ChatEventDto.MessageUpdated` for the same `messageId`.
|
||||
* Latest snapshot wins.
|
||||
* - `ChatEventDto.SessionStatusChanged` for the same `sessionId`.
|
||||
* Latest snapshot wins.
|
||||
* - `ChatEventDto.SessionDiffChanged` for the same `sessionId`.
|
||||
* Latest snapshot wins.
|
||||
*
|
||||
* ## What is not merged
|
||||
*
|
||||
* - `PartDelta` for non-text fields
|
||||
* - `PartDelta` and `PartUpdated` do not merge across each other
|
||||
* - No event merges across a barrier (TurnOpen, TurnClose, Error, etc.)
|
||||
*
|
||||
* ## Drain order
|
||||
*
|
||||
* When a barrier is encountered or the batch ends, accumulators drain in this
|
||||
* order: state events first, then part updates, then text deltas. This ensures
|
||||
* a message is always flushed before the part updates that depend on it.
|
||||
*/
|
||||
internal class SessionQueueCondenser {
|
||||
|
||||
fun condense(events: List<ChatEventDto>): List<ChatEventDto> {
|
||||
if (events.size < 2) return events
|
||||
val out = mutableListOf<ChatEventDto>()
|
||||
val deltas = LinkedHashMap<String, ChatEventDto.PartDelta>()
|
||||
val parts = LinkedHashMap<String, ChatEventDto.PartUpdated>()
|
||||
val states = LinkedHashMap<String, ChatEventDto>()
|
||||
|
||||
fun drainDeltas() {
|
||||
if (deltas.isEmpty()) return
|
||||
out.addAll(deltas.values)
|
||||
deltas.clear()
|
||||
}
|
||||
|
||||
fun drainParts() {
|
||||
if (parts.isEmpty()) return
|
||||
out.addAll(parts.values)
|
||||
parts.clear()
|
||||
}
|
||||
|
||||
fun drainStates() {
|
||||
if (states.isEmpty()) return
|
||||
out.addAll(states.values)
|
||||
states.clear()
|
||||
}
|
||||
|
||||
fun drain() {
|
||||
drainStates()
|
||||
drainParts()
|
||||
drainDeltas()
|
||||
}
|
||||
|
||||
for (event in events) {
|
||||
when (event) {
|
||||
is ChatEventDto.PartDelta -> {
|
||||
val key = event.key()
|
||||
if (key == null) {
|
||||
drain()
|
||||
out.add(event)
|
||||
continue
|
||||
}
|
||||
drainParts()
|
||||
drainStates()
|
||||
val prev = deltas[key]
|
||||
deltas[key] = if (prev != null) prev.merge(event) else event
|
||||
}
|
||||
|
||||
is ChatEventDto.PartUpdated -> {
|
||||
drainDeltas()
|
||||
drainStates()
|
||||
parts[event.key()] = event
|
||||
}
|
||||
|
||||
is ChatEventDto.MessageUpdated -> {
|
||||
drainDeltas()
|
||||
drainParts()
|
||||
states["MU:${event.info.id}"] = event
|
||||
}
|
||||
|
||||
is ChatEventDto.SessionStatusChanged -> {
|
||||
drainDeltas()
|
||||
drainParts()
|
||||
states["SC:${event.sessionID}"] = event
|
||||
}
|
||||
|
||||
is ChatEventDto.SessionDiffChanged -> {
|
||||
drainDeltas()
|
||||
drainParts()
|
||||
states["SDC:${event.sessionID}"] = event
|
||||
}
|
||||
|
||||
else -> {
|
||||
drain()
|
||||
out.add(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drain()
|
||||
return out
|
||||
}
|
||||
|
||||
private fun ChatEventDto.PartDelta.key(): String? {
|
||||
if (field != "text") return null
|
||||
return "$sessionID:$messageID:$partID:$field"
|
||||
}
|
||||
|
||||
private fun ChatEventDto.PartUpdated.key(): String =
|
||||
"$sessionID:${part.messageID}:${part.id}"
|
||||
|
||||
private fun ChatEventDto.PartDelta.merge(next: ChatEventDto.PartDelta): ChatEventDto.PartDelta =
|
||||
ChatEventDto.PartDelta(next.sessionID, next.messageID, next.partID, next.field, delta + next.delta)
|
||||
}
|
||||
+11
-1
@@ -13,6 +13,7 @@ import ai.kilocode.client.session.ui.SessionPanel
|
||||
import ai.kilocode.client.session.ui.StatusPanel
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.registry.Registry
|
||||
import ai.kilocode.log.ChatLogSummary
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
@@ -52,7 +53,16 @@ class SessionUi(
|
||||
private val LOG = KiloLog.create(SessionUi::class.java)
|
||||
}
|
||||
|
||||
private val controller = SessionController(this, null, sessions, workspace, app, cs)
|
||||
private val flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt())
|
||||
.takeIf { it > 0 }
|
||||
?.toLong()
|
||||
?: EVENT_FLUSH_MS
|
||||
|
||||
private val controller = SessionController(
|
||||
this, null, sessions, workspace, app, cs, this,
|
||||
flushMs = flushMs,
|
||||
condense = Registry.`is`("kilo.session.condense", true),
|
||||
)
|
||||
|
||||
// ------ card switch ------
|
||||
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package ai.kilocode.client.session
|
||||
|
||||
import ai.kilocode.log.ChatLogSummary
|
||||
import ai.kilocode.log.KiloLog
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import java.awt.Component
|
||||
import java.awt.event.HierarchyEvent
|
||||
import java.awt.event.HierarchyListener
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal const val EVENT_FLUSH_MS = 150L
|
||||
|
||||
internal class SessionUpdateQueue(
|
||||
parent: Disposable,
|
||||
private val comp: Component?,
|
||||
private val flushMs: Long = EVENT_FLUSH_MS,
|
||||
private val fire: (List<ChatEventDto>) -> Unit,
|
||||
private val condense: Boolean = true,
|
||||
hold: Boolean,
|
||||
private val sid: () -> String,
|
||||
) : Disposable {
|
||||
companion object {
|
||||
private val LOG = KiloLog.create(SessionUpdateQueue::class.java)
|
||||
}
|
||||
|
||||
private val app = ApplicationManager.getApplication()
|
||||
private val condenser = SessionQueueCondenser()
|
||||
private val pending = mutableListOf<ChatEventDto>()
|
||||
private val lock = Any()
|
||||
private val exec: ScheduledExecutorService? = if (flushMs == Long.MAX_VALUE) null else Executors.newSingleThreadScheduledExecutor()
|
||||
private val visible = AtomicBoolean(comp?.isShowing ?: true)
|
||||
private val watch = comp?.let {
|
||||
HierarchyListener { event ->
|
||||
if (event.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@HierarchyListener
|
||||
onVisible(it.isShowing)
|
||||
}
|
||||
}
|
||||
private var last = 0L
|
||||
private var hold = hold
|
||||
|
||||
init {
|
||||
Disposer.register(parent, this)
|
||||
if (comp != null && watch != null) comp.addHierarchyListener(watch)
|
||||
exec?.scheduleAtFixedRate(
|
||||
{
|
||||
if (!visible.get()) return@scheduleAtFixedRate
|
||||
requestFlush(false, "tick")
|
||||
},
|
||||
flushMs,
|
||||
flushMs,
|
||||
TimeUnit.MILLISECONDS,
|
||||
)
|
||||
}
|
||||
|
||||
fun enqueue(event: ChatEventDto) {
|
||||
val size = synchronized(lock) {
|
||||
pending.add(event)
|
||||
pending.size
|
||||
}
|
||||
LOG.debug { "${ChatLogSummary.sid(sid())} enqueue pending=$size visible=${visible.get()}" }
|
||||
if (!visible.get()) return
|
||||
requestFlush(false, "enqueue")
|
||||
}
|
||||
|
||||
fun holdFlush(hold: Boolean) {
|
||||
edt {
|
||||
LOG.debug { "${ChatLogSummary.sid(sid())} hold=$hold" }
|
||||
this.hold = hold
|
||||
}
|
||||
}
|
||||
|
||||
fun requestFlush(forced: Boolean, source: String = "api") {
|
||||
if (!forced && !visible.get()) return
|
||||
edt { flushNow(forced, source) }
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
val size = synchronized(lock) { pending.size }
|
||||
LOG.debug { "${ChatLogSummary.sid(sid())} dispose pending=$size" }
|
||||
exec?.shutdownNow()
|
||||
if (comp != null && watch != null) comp.removeHierarchyListener(watch)
|
||||
if (app.isDispatchThread) {
|
||||
synchronized(lock) { pending.clear() }
|
||||
return
|
||||
}
|
||||
app.invokeLater { synchronized(lock) { pending.clear() } }
|
||||
}
|
||||
|
||||
private fun flushNow(forced: Boolean, source: String) {
|
||||
if (hold) return
|
||||
if (!visible.get()) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (!forced && now - last < flushMs) return
|
||||
val batch = synchronized(lock) {
|
||||
if (pending.isEmpty()) return
|
||||
pending.toList().also { pending.clear() }
|
||||
}
|
||||
val before = batch.size
|
||||
val types = batch.groupBy { it::class.simpleName }
|
||||
.entries.joinToString(",") { (k, v) -> "$k:${v.size}" }
|
||||
val out = if (condense) condenser.condense(batch) else batch
|
||||
last = now
|
||||
LOG.debug { "${ChatLogSummary.sid(sid())} flush source=$source forced=$forced pending=$before condensed=${out.size} saved=${before - out.size} types=$types" }
|
||||
fire(out)
|
||||
}
|
||||
|
||||
private fun onVisible(show: Boolean) {
|
||||
val prev = visible.getAndSet(show)
|
||||
if (prev == show) return
|
||||
LOG.debug { "${ChatLogSummary.sid(sid())} visible=$show" }
|
||||
if (!show) return
|
||||
requestFlush(true, "visible")
|
||||
}
|
||||
|
||||
private fun edt(block: () -> Unit) {
|
||||
if (app.isDispatchThread) {
|
||||
block()
|
||||
return
|
||||
}
|
||||
app.invokeLater(block)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,17 @@
|
||||
anchor="left"
|
||||
icon="/icons/kilo.svg"
|
||||
factoryClass="ai.kilocode.client.KiloToolWindowFactory"/>
|
||||
|
||||
<registryKey key="kilo.session.condense"
|
||||
description="Enable event condensing in the session update queue (merges redundant snapshots before model delivery)."
|
||||
defaultValue="true"
|
||||
restartRequired="false"
|
||||
overrides="false"/>
|
||||
<registryKey key="kilo.session.flushMs"
|
||||
description="Session update queue flush cadence in milliseconds."
|
||||
defaultValue="150"
|
||||
restartRequired="false"
|
||||
overrides="false"/>
|
||||
</extensions>
|
||||
|
||||
<actions>
|
||||
|
||||
+1
-5
@@ -46,16 +46,12 @@ class ListenerLifecycleTest : SessionControllerTestBase() {
|
||||
edt { m.prompt("go") }
|
||||
flush()
|
||||
|
||||
assertEquals(events1, events2)
|
||||
assertControllerEvents("""
|
||||
ViewChanged show
|
||||
AppChanged
|
||||
WorkspaceChanged
|
||||
""", events1)
|
||||
assertControllerEvents("""
|
||||
ViewChanged show
|
||||
AppChanged
|
||||
WorkspaceChanged
|
||||
""", events2)
|
||||
}
|
||||
|
||||
fun `test session status idle fires StateChanged to Idle`() {
|
||||
|
||||
+88
-4
@@ -4,6 +4,7 @@ import ai.kilocode.client.app.KiloAppService
|
||||
import ai.kilocode.client.app.KiloSessionService
|
||||
import ai.kilocode.client.session.model.SessionModel
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.testing.FakeAppRpcApi
|
||||
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
|
||||
import ai.kilocode.client.testing.FakeSessionRpcApi
|
||||
@@ -27,6 +28,7 @@ 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 java.awt.event.HierarchyEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
@@ -41,6 +43,45 @@ import kotlinx.coroutines.runBlocking
|
||||
*/
|
||||
abstract class SessionControllerTestBase : BasePlatformTestCase() {
|
||||
|
||||
protected data class Snapshot(
|
||||
val body: String,
|
||||
val turns: String,
|
||||
val state: SessionState,
|
||||
val diff: List<ai.kilocode.rpc.dto.DiffFileDto>,
|
||||
val todos: List<ai.kilocode.rpc.dto.TodoDto>,
|
||||
val compacted: Int,
|
||||
) {
|
||||
override fun toString(): String = buildString {
|
||||
appendLine("state=$state")
|
||||
appendLine("turns=$turns")
|
||||
appendLine("diff=$diff")
|
||||
appendLine("todos=$todos")
|
||||
appendLine("compacted=$compacted")
|
||||
append("body=\n$body")
|
||||
}
|
||||
}
|
||||
|
||||
private class Root : javax.swing.JPanel() {
|
||||
private var shown = true
|
||||
override fun isShowing(): Boolean = shown
|
||||
fun showState(show: Boolean) {
|
||||
val prev = shown
|
||||
shown = show
|
||||
if (prev == show) return
|
||||
val event = HierarchyEvent(
|
||||
this,
|
||||
HierarchyEvent.HIERARCHY_CHANGED,
|
||||
this,
|
||||
this.parent,
|
||||
HierarchyEvent.SHOWING_CHANGED.toLong(),
|
||||
)
|
||||
hierarchyListeners.forEach { it.hierarchyChanged(event) }
|
||||
}
|
||||
}
|
||||
|
||||
private val controllers = mutableListOf<SessionController>()
|
||||
private val roots = mutableMapOf<SessionController, Root>()
|
||||
|
||||
protected lateinit var rpc: FakeSessionRpcApi
|
||||
protected lateinit var appRpc: FakeAppRpcApi
|
||||
protected lateinit var projectRpc: FakeWorkspaceRpcApi
|
||||
@@ -79,8 +120,27 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
|
||||
|
||||
// ------ Controller creation ------
|
||||
|
||||
protected fun controller(id: String? = null) =
|
||||
SessionController(parent, id, sessions, workspace, app, scope)
|
||||
protected fun controller(id: String? = null) = controller(id, Long.MAX_VALUE)
|
||||
|
||||
protected fun controller(id: String? = null, flushMs: Long): SessionController {
|
||||
return controller(id, flushMs, true)
|
||||
}
|
||||
|
||||
protected fun controller(id: String? = null, flushMs: Long, condense: Boolean): SessionController {
|
||||
val root = Root()
|
||||
val m = SessionController(parent, id, sessions, workspace, app, scope, root, flushMs, condense)
|
||||
controllers.add(m)
|
||||
roots[m] = root
|
||||
return m
|
||||
}
|
||||
|
||||
protected fun hide(m: SessionController) {
|
||||
edt { (roots[m] ?: error("missing root")).showState(false) }
|
||||
}
|
||||
|
||||
protected fun show(m: SessionController) {
|
||||
edt { (roots[m] ?: error("missing root")).showState(true) }
|
||||
}
|
||||
|
||||
// ------ Event collection ------
|
||||
|
||||
@@ -109,10 +169,19 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
|
||||
|
||||
// ------ EDT + coroutine helpers ------
|
||||
|
||||
/** Let coroutines settle, then drain all pending EDT events. */
|
||||
/** Let coroutines settle without forcing buffered controller delivery. */
|
||||
protected fun settle() = runBlocking {
|
||||
repeat(5) {
|
||||
delay(100)
|
||||
edt { UIUtil.dispatchAllInvocationEvents() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Let coroutines settle, force buffered controller delivery, then drain EDT. */
|
||||
protected fun flush() = runBlocking {
|
||||
repeat(5) {
|
||||
delay(100)
|
||||
controllers.forEach { it.flushEvents() }
|
||||
edt { UIUtil.dispatchAllInvocationEvents() }
|
||||
}
|
||||
}
|
||||
@@ -154,13 +223,24 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
|
||||
}
|
||||
|
||||
protected fun assertControllerEvents(expected: String, events: List<SessionControllerEvent>) {
|
||||
assertEquals(expected.trimIndent().trim(), events.joinToString("\n"))
|
||||
val exp = expected.trimIndent().lines().map { it.trim() }.filter { it.isNotEmpty() }.sorted()
|
||||
val act = events.map { it.toString() }.sorted()
|
||||
assertEquals(exp.joinToString("\n"), act.joinToString("\n"))
|
||||
}
|
||||
|
||||
protected fun assertModelEvents(expected: String, events: List<SessionModelEvent>) {
|
||||
assertEquals(expected.trimIndent().trim(), events.joinToString("\n"))
|
||||
}
|
||||
|
||||
protected fun snapshot(c: SessionController) = Snapshot(
|
||||
body = c.model.toString().trim(),
|
||||
turns = c.model.toTurnsString().trim(),
|
||||
state = c.model.state,
|
||||
diff = c.model.diff.toList(),
|
||||
todos = c.model.todos.toList(),
|
||||
compacted = c.model.compactionCount,
|
||||
)
|
||||
|
||||
// ------ DTO factories ------
|
||||
|
||||
protected fun msg(id: String, sid: String, role: String) = MessageDto(
|
||||
@@ -177,6 +257,8 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
|
||||
type: String,
|
||||
text: String? = null,
|
||||
tool: String? = null,
|
||||
state: String? = null,
|
||||
title: String? = null,
|
||||
) = PartDto(
|
||||
id = id,
|
||||
sessionID = sid,
|
||||
@@ -184,6 +266,8 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
|
||||
type = type,
|
||||
text = text,
|
||||
tool = tool,
|
||||
state = state,
|
||||
title = title,
|
||||
)
|
||||
|
||||
protected fun workspaceReady(
|
||||
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
package ai.kilocode.client.session
|
||||
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.MessageDto
|
||||
import ai.kilocode.rpc.dto.MessageTimeDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.SessionStatusDto
|
||||
import junit.framework.TestCase
|
||||
|
||||
class SessionQueueCondenserTest : TestCase() {
|
||||
|
||||
private val condenser = SessionQueueCondenser()
|
||||
|
||||
private fun delta(msg: String, part: String, text: String) =
|
||||
ChatEventDto.PartDelta("ses", msg, part, "text", text)
|
||||
|
||||
private fun updated(
|
||||
msg: String,
|
||||
part: String,
|
||||
type: String,
|
||||
text: String? = null,
|
||||
tool: String? = null,
|
||||
state: String? = null,
|
||||
title: String? = null,
|
||||
) = ChatEventDto.PartUpdated(
|
||||
"ses",
|
||||
PartDto(part, "ses", msg, type, text = text, tool = tool, state = state, title = title),
|
||||
)
|
||||
|
||||
private fun nonDelta(msg: String) =
|
||||
ChatEventDto.TurnOpen(msg)
|
||||
|
||||
fun `test empty list returns empty`() {
|
||||
assertEquals(emptyList<ChatEventDto>(), condenser.condense(emptyList()))
|
||||
}
|
||||
|
||||
fun `test single event returned unchanged`() {
|
||||
val event = delta("m1", "p1", "hi")
|
||||
assertEquals(listOf(event), condenser.condense(listOf(event)))
|
||||
}
|
||||
|
||||
fun `test two deltas for same part are merged`() {
|
||||
val result = condenser.condense(listOf(
|
||||
delta("m1", "p1", "hello "),
|
||||
delta("m1", "p1", "world"),
|
||||
))
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("hello world", (result[0] as ChatEventDto.PartDelta).delta)
|
||||
}
|
||||
|
||||
fun `test many deltas for same part are all merged`() {
|
||||
val result = condenser.condense(listOf(
|
||||
delta("m1", "p1", "a"),
|
||||
delta("m1", "p1", "b"),
|
||||
delta("m1", "p1", "c"),
|
||||
))
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("abc", (result[0] as ChatEventDto.PartDelta).delta)
|
||||
}
|
||||
|
||||
fun `test deltas for different parts are kept separate`() {
|
||||
val result = condenser.condense(listOf(
|
||||
delta("m1", "p1", "foo"),
|
||||
delta("m1", "p2", "bar"),
|
||||
))
|
||||
assertEquals(2, result.size)
|
||||
assertEquals("foo", (result[0] as ChatEventDto.PartDelta).delta)
|
||||
assertEquals("bar", (result[1] as ChatEventDto.PartDelta).delta)
|
||||
}
|
||||
|
||||
fun `test non-text field deltas are not merged`() {
|
||||
val d1 = ChatEventDto.PartDelta("ses", "m1", "p1", "tool_call", "chunk1")
|
||||
val d2 = ChatEventDto.PartDelta("ses", "m1", "p1", "tool_call", "chunk2")
|
||||
val result = condenser.condense(listOf(d1, d2))
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
fun `test non-delta event flushes pending deltas before it`() {
|
||||
val barrier = nonDelta("turn1")
|
||||
val result = condenser.condense(listOf(
|
||||
delta("m1", "p1", "x"),
|
||||
delta("m1", "p1", "y"),
|
||||
barrier,
|
||||
delta("m1", "p1", "z"),
|
||||
))
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("xy", (result[0] as ChatEventDto.PartDelta).delta)
|
||||
assertEquals(barrier, result[1])
|
||||
assertEquals("z", (result[2] as ChatEventDto.PartDelta).delta)
|
||||
}
|
||||
|
||||
fun `test deltas after barrier are merged independently`() {
|
||||
val result = condenser.condense(listOf(
|
||||
delta("m1", "p1", "a"),
|
||||
nonDelta("t"),
|
||||
delta("m1", "p1", "b"),
|
||||
delta("m1", "p1", "c"),
|
||||
))
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("a", (result[0] as ChatEventDto.PartDelta).delta)
|
||||
assertEquals("bc", (result[2] as ChatEventDto.PartDelta).delta)
|
||||
}
|
||||
|
||||
fun `test deltas for different messages are kept separate`() {
|
||||
val result = condenser.condense(listOf(
|
||||
delta("m1", "p1", "hi"),
|
||||
delta("m2", "p1", "there"),
|
||||
))
|
||||
assertEquals(2, result.size)
|
||||
assertEquals("hi", (result[0] as ChatEventDto.PartDelta).delta)
|
||||
assertEquals("there", (result[1] as ChatEventDto.PartDelta).delta)
|
||||
}
|
||||
|
||||
fun `test merged delta uses session and part ids from last event`() {
|
||||
val result = condenser.condense(listOf(
|
||||
delta("m1", "p1", "first"),
|
||||
delta("m1", "p1", "second"),
|
||||
))
|
||||
val event = result.single() as ChatEventDto.PartDelta
|
||||
assertEquals("ses", event.sessionID)
|
||||
assertEquals("m1", event.messageID)
|
||||
assertEquals("p1", event.partID)
|
||||
}
|
||||
|
||||
fun `test consecutive same part updates keep only latest snapshot`() {
|
||||
val result = condenser.condense(listOf(
|
||||
updated("m1", "p1", "tool", tool = "bash", state = "pending"),
|
||||
updated("m1", "p1", "tool", tool = "bash", state = "completed", title = "Install deps"),
|
||||
))
|
||||
|
||||
assertEquals(1, result.size)
|
||||
val event = result.single() as ChatEventDto.PartUpdated
|
||||
assertEquals("completed", event.part.state)
|
||||
assertEquals("Install deps", event.part.title)
|
||||
}
|
||||
|
||||
fun `test consecutive text part updates keep final text`() {
|
||||
val result = condenser.condense(listOf(
|
||||
updated("m1", "p1", "text", text = "hel"),
|
||||
updated("m1", "p1", "text", text = "hello"),
|
||||
))
|
||||
|
||||
assertEquals(1, result.size)
|
||||
val event = result.single() as ChatEventDto.PartUpdated
|
||||
assertEquals("hello", event.part.text)
|
||||
}
|
||||
|
||||
fun `test part updates for different parts are kept separate`() {
|
||||
val result = condenser.condense(listOf(
|
||||
updated("m1", "p1", "tool", tool = "bash"),
|
||||
updated("m1", "p2", "tool", tool = "edit"),
|
||||
))
|
||||
|
||||
assertEquals(2, result.size)
|
||||
assertEquals("p1", (result[0] as ChatEventDto.PartUpdated).part.id)
|
||||
assertEquals("p2", (result[1] as ChatEventDto.PartUpdated).part.id)
|
||||
}
|
||||
|
||||
fun `test part updates for different messages are kept separate`() {
|
||||
val result = condenser.condense(listOf(
|
||||
updated("m1", "p1", "tool", tool = "bash"),
|
||||
updated("m2", "p1", "tool", tool = "bash"),
|
||||
))
|
||||
|
||||
assertEquals(2, result.size)
|
||||
assertEquals("m1", (result[0] as ChatEventDto.PartUpdated).part.messageID)
|
||||
assertEquals("m2", (result[1] as ChatEventDto.PartUpdated).part.messageID)
|
||||
}
|
||||
|
||||
fun `test barrier flushes pending part updates before it`() {
|
||||
val barrier = nonDelta("turn1")
|
||||
val result = condenser.condense(listOf(
|
||||
updated("m1", "p1", "tool", tool = "bash", state = "pending"),
|
||||
updated("m1", "p1", "tool", tool = "bash", state = "running"),
|
||||
barrier,
|
||||
updated("m1", "p1", "tool", tool = "bash", state = "completed"),
|
||||
))
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("running", (result[0] as ChatEventDto.PartUpdated).part.state)
|
||||
assertEquals(barrier, result[1])
|
||||
assertEquals("completed", (result[2] as ChatEventDto.PartUpdated).part.state)
|
||||
}
|
||||
|
||||
fun `test delta acts as barrier for part updates`() {
|
||||
val result = condenser.condense(listOf(
|
||||
updated("m1", "p1", "text", text = "he"),
|
||||
delta("m1", "p1", "l"),
|
||||
updated("m1", "p1", "text", text = "hello"),
|
||||
))
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("he", (result[0] as ChatEventDto.PartUpdated).part.text)
|
||||
assertEquals("l", (result[1] as ChatEventDto.PartDelta).delta)
|
||||
assertEquals("hello", (result[2] as ChatEventDto.PartUpdated).part.text)
|
||||
}
|
||||
|
||||
fun `test merged part update matches latest payload exactly`() {
|
||||
val first = updated("m1", "p1", "tool", tool = "bash", state = "pending")
|
||||
val last = updated("m1", "p1", "tool", tool = "edit", state = "running", title = "Apply patch")
|
||||
|
||||
val result = condenser.condense(listOf(first, last))
|
||||
|
||||
assertEquals(listOf(last), result)
|
||||
}
|
||||
|
||||
// ------ MessageUpdated coalescing ------
|
||||
|
||||
fun `test consecutive message updates for same id keep only latest`() {
|
||||
val first = msgUpdated("m1", role = "assistant")
|
||||
val last = msgUpdated("m1", role = "assistant", cost = 0.02)
|
||||
|
||||
val result = condenser.condense(listOf(first, last))
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(last, result[0])
|
||||
}
|
||||
|
||||
fun `test message updates for different ids are kept separate`() {
|
||||
val result = condenser.condense(listOf(
|
||||
msgUpdated("m1"),
|
||||
msgUpdated("m2"),
|
||||
))
|
||||
|
||||
assertEquals(2, result.size)
|
||||
assertEquals("m1", (result[0] as ChatEventDto.MessageUpdated).info.id)
|
||||
assertEquals("m2", (result[1] as ChatEventDto.MessageUpdated).info.id)
|
||||
}
|
||||
|
||||
fun `test barrier flushes pending message updates before it`() {
|
||||
val barrier = nonDelta("turn1")
|
||||
val result = condenser.condense(listOf(
|
||||
msgUpdated("m1"),
|
||||
barrier,
|
||||
msgUpdated("m1", cost = 0.05),
|
||||
))
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertNull((result[0] as ChatEventDto.MessageUpdated).info.cost)
|
||||
assertEquals(barrier, result[1])
|
||||
assertEquals(0.05, (result[2] as ChatEventDto.MessageUpdated).info.cost)
|
||||
}
|
||||
|
||||
// ------ SessionStatusChanged coalescing ------
|
||||
|
||||
fun `test consecutive status changes keep only latest`() {
|
||||
val busy = statusChanged("busy")
|
||||
val idle = statusChanged("idle")
|
||||
|
||||
val result = condenser.condense(listOf(busy, idle))
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("idle", (result[0] as ChatEventDto.SessionStatusChanged).status.type)
|
||||
}
|
||||
|
||||
fun `test status changes for different sessions kept separate`() {
|
||||
val result = condenser.condense(listOf(
|
||||
ChatEventDto.SessionStatusChanged("ses1", SessionStatusDto("busy")),
|
||||
ChatEventDto.SessionStatusChanged("ses2", SessionStatusDto("idle")),
|
||||
))
|
||||
|
||||
assertEquals(2, result.size)
|
||||
assertEquals("ses1", (result[0] as ChatEventDto.SessionStatusChanged).sessionID)
|
||||
assertEquals("ses2", (result[1] as ChatEventDto.SessionStatusChanged).sessionID)
|
||||
}
|
||||
|
||||
fun `test barrier flushes pending status change before it`() {
|
||||
val barrier = nonDelta("turn1")
|
||||
val result = condenser.condense(listOf(
|
||||
statusChanged("busy"),
|
||||
barrier,
|
||||
statusChanged("idle"),
|
||||
))
|
||||
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("busy", (result[0] as ChatEventDto.SessionStatusChanged).status.type)
|
||||
assertEquals(barrier, result[1])
|
||||
assertEquals("idle", (result[2] as ChatEventDto.SessionStatusChanged).status.type)
|
||||
}
|
||||
|
||||
// ------ SessionDiffChanged coalescing ------
|
||||
|
||||
fun `test consecutive diff changes keep only latest`() {
|
||||
val first = ChatEventDto.SessionDiffChanged("ses", listOf(DiffFileDto("a.kt", 1, 0)))
|
||||
val last = ChatEventDto.SessionDiffChanged("ses", listOf(DiffFileDto("b.kt", 2, 1)))
|
||||
|
||||
val result = condenser.condense(listOf(first, last))
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(last, result[0])
|
||||
}
|
||||
|
||||
// ------ State-event / content-event drain ordering ------
|
||||
|
||||
fun `test mixed batch with two message updates same and status change is condensed`() {
|
||||
val result = condenser.condense(listOf(
|
||||
msgUpdated("m1"),
|
||||
msgUpdated("m1", cost = 0.02),
|
||||
statusChanged("busy"),
|
||||
statusChanged("idle"),
|
||||
ChatEventDto.SessionDiffChanged("ses", listOf(DiffFileDto("x.kt", 1, 0))),
|
||||
))
|
||||
|
||||
// 2 MU → 1, 2 SSC → 1, 1 SDC → 1 = 3 total
|
||||
assertEquals(3, result.size)
|
||||
assertEquals(0.02, (result[0] as ChatEventDto.MessageUpdated).info.cost)
|
||||
assertEquals("idle", (result[1] as ChatEventDto.SessionStatusChanged).status.type)
|
||||
assertTrue(result[2] is ChatEventDto.SessionDiffChanged)
|
||||
}
|
||||
|
||||
fun `test message update is emitted before part update for same message`() {
|
||||
// Server always sends MessageUpdated before PartUpdated for a new message.
|
||||
// Condensing must preserve that semantic ordering.
|
||||
val result = condenser.condense(listOf(
|
||||
msgUpdated("m1"),
|
||||
updated("m1", "p1", "text", text = "hello"),
|
||||
))
|
||||
|
||||
assertEquals(2, result.size)
|
||||
assertTrue(result[0] is ChatEventDto.MessageUpdated)
|
||||
assertTrue(result[1] is ChatEventDto.PartUpdated)
|
||||
}
|
||||
|
||||
fun `test part updates for same part coalesce across interleaved message update`() {
|
||||
// Both PUs are for the same part but separated by a MU.
|
||||
// MU drains and flushes the first PU, so they do NOT merge.
|
||||
val result = condenser.condense(listOf(
|
||||
updated("m1", "p1", "tool", state = "running"),
|
||||
msgUpdated("m1", cost = 0.01),
|
||||
updated("m1", "p1", "tool", state = "completed"),
|
||||
))
|
||||
|
||||
// running is flushed when MU arrives, completed is a new batch → cannot merge
|
||||
assertEquals(3, result.size)
|
||||
assertEquals("running", (result[0] as ChatEventDto.PartUpdated).part.state)
|
||||
assertNotNull(result[1] as? ChatEventDto.MessageUpdated)
|
||||
assertEquals("completed", (result[2] as ChatEventDto.PartUpdated).part.state)
|
||||
}
|
||||
|
||||
// ------ helpers ------
|
||||
|
||||
private fun msgUpdated(id: String, role: String = "assistant", cost: Double? = null) =
|
||||
ChatEventDto.MessageUpdated(
|
||||
"ses",
|
||||
MessageDto(id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0), cost = cost),
|
||||
)
|
||||
|
||||
private fun statusChanged(type: String) =
|
||||
ChatEventDto.SessionStatusChanged("ses", SessionStatusDto(type))
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
package ai.kilocode.client.session
|
||||
|
||||
import ai.kilocode.client.session.model.Tool
|
||||
import ai.kilocode.client.session.model.ToolExecState
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.SessionStatusDto
|
||||
import ai.kilocode.rpc.dto.TodoDto
|
||||
|
||||
class SessionUpdateQueueTest : SessionControllerTestBase() {
|
||||
|
||||
fun `test hidden controller buffers until shown`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = 250L)
|
||||
val modelEvents = collectModelEvents(m)
|
||||
flush()
|
||||
modelEvents.clear()
|
||||
|
||||
hide(m)
|
||||
emit(ChatEventDto.TurnOpen("ses_test"), flush = false)
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false)
|
||||
settle()
|
||||
|
||||
assertTrue(modelEvents.isEmpty())
|
||||
assertEquals(SessionState.Idle, m.model.state)
|
||||
|
||||
show(m)
|
||||
settle()
|
||||
|
||||
assertModelEvents("""
|
||||
StateChanged Busy
|
||||
MessageAdded msg1
|
||||
TurnAdded msg1 [msg1]
|
||||
""", modelEvents)
|
||||
assertTrue(m.model.state is SessionState.Busy)
|
||||
}
|
||||
|
||||
fun `test hidden controller condenses while hidden but does not flush`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = 250L)
|
||||
val modelEvents = collectModelEvents(m)
|
||||
flush()
|
||||
modelEvents.clear()
|
||||
|
||||
hide(m)
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false)
|
||||
repeat(4) { i ->
|
||||
emit(ChatEventDto.PartDelta("ses_test", "msg1", "txt1", "text", " chunk$i"), flush = false)
|
||||
}
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "bash", state = "running")), flush = false)
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "bash", state = "completed", title = "Run build")), flush = false)
|
||||
settle()
|
||||
|
||||
assertTrue(modelEvents.isEmpty())
|
||||
assertEquals(SessionState.Idle, m.model.state)
|
||||
|
||||
show(m)
|
||||
settle()
|
||||
|
||||
assertModelEvents("""
|
||||
MessageAdded msg1
|
||||
TurnAdded msg1 [msg1]
|
||||
ContentAdded msg1/txt1
|
||||
ContentDelta msg1/txt1
|
||||
ContentAdded msg1/tool1
|
||||
""", modelEvents)
|
||||
assertModel(
|
||||
"""
|
||||
assistant#msg1
|
||||
text#txt1:
|
||||
chunk0 chunk1 chunk2 chunk3
|
||||
tool#tool1 bash [COMPLETED] Run build
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test hidden cadence does not flush until shown`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = 50L)
|
||||
val modelEvents = collectModelEvents(m)
|
||||
flush()
|
||||
modelEvents.clear()
|
||||
|
||||
hide(m)
|
||||
emit(ChatEventDto.TurnOpen("ses_test"), flush = false)
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false)
|
||||
settle()
|
||||
|
||||
assertTrue(modelEvents.isEmpty())
|
||||
assertEquals(SessionState.Idle, m.model.state)
|
||||
|
||||
show(m)
|
||||
settle()
|
||||
|
||||
assertModelEvents("""
|
||||
StateChanged Busy
|
||||
MessageAdded msg1
|
||||
TurnAdded msg1 [msg1]
|
||||
""", modelEvents)
|
||||
}
|
||||
|
||||
fun `test hidden controller flushes on show without new event`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = 250L)
|
||||
val modelEvents = collectModelEvents(m)
|
||||
flush()
|
||||
modelEvents.clear()
|
||||
|
||||
hide(m)
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false)
|
||||
emit(ChatEventDto.PartDelta("ses_test", "msg1", "txt1", "text", "hello "), flush = false)
|
||||
emit(ChatEventDto.PartDelta("ses_test", "msg1", "txt1", "text", "world"), flush = false)
|
||||
settle()
|
||||
|
||||
assertTrue(modelEvents.isEmpty())
|
||||
|
||||
show(m)
|
||||
settle()
|
||||
|
||||
assertModelEvents("""
|
||||
MessageAdded msg1
|
||||
TurnAdded msg1 [msg1]
|
||||
ContentAdded msg1/txt1
|
||||
ContentDelta msg1/txt1
|
||||
""", modelEvents)
|
||||
assertModel(
|
||||
"""
|
||||
assistant#msg1
|
||||
text#txt1:
|
||||
hello world
|
||||
""",
|
||||
m,
|
||||
)
|
||||
}
|
||||
|
||||
fun `test buffered deltas coalesce into one model delta`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = Long.MAX_VALUE)
|
||||
val modelEvents = collectModelEvents(m)
|
||||
flush()
|
||||
modelEvents.clear()
|
||||
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
|
||||
modelEvents.clear()
|
||||
|
||||
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "hello "), flush = false)
|
||||
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world"), flush = false)
|
||||
settle()
|
||||
flush()
|
||||
|
||||
assertEquals(1, modelEvents.count { it is SessionModelEvent.ContentAdded })
|
||||
val delta = modelEvents.filterIsInstance<SessionModelEvent.ContentDelta>()
|
||||
assertEquals(1, delta.size)
|
||||
assertModel(
|
||||
"""
|
||||
assistant#msg1
|
||||
text#prt1:
|
||||
hello world
|
||||
""",
|
||||
m,
|
||||
)
|
||||
assertEquals(listOf("hello world"), delta.map { it.delta })
|
||||
}
|
||||
|
||||
fun `test visible controller flushes after cadence`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = 50L)
|
||||
val modelEvents = collectModelEvents(m)
|
||||
flush()
|
||||
modelEvents.clear()
|
||||
|
||||
emit(ChatEventDto.TurnOpen("ses_test"), flush = false)
|
||||
flush()
|
||||
|
||||
assertTrue(modelEvents.any { it is SessionModelEvent.StateChanged })
|
||||
assertTrue(m.model.state is SessionState.Busy)
|
||||
}
|
||||
|
||||
fun `test buffered part updates for new part collapse to one content add`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = Long.MAX_VALUE)
|
||||
val modelEvents = collectModelEvents(m)
|
||||
flush()
|
||||
modelEvents.clear()
|
||||
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
|
||||
modelEvents.clear()
|
||||
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "pending")), flush = false)
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "completed")), flush = false)
|
||||
settle()
|
||||
flush()
|
||||
|
||||
assertEquals(1, modelEvents.count { it is SessionModelEvent.ContentAdded })
|
||||
assertEquals(0, modelEvents.count { it is SessionModelEvent.ContentUpdated })
|
||||
val tool = m.model.message("msg1")!!.parts["prt1"] as Tool
|
||||
assertEquals(ToolExecState.COMPLETED, tool.state)
|
||||
}
|
||||
|
||||
fun `test buffered part updates for existing part collapse to one content update`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = Long.MAX_VALUE)
|
||||
val modelEvents = collectModelEvents(m)
|
||||
flush()
|
||||
modelEvents.clear()
|
||||
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "pending")))
|
||||
modelEvents.clear()
|
||||
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "running")), flush = false)
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "completed", title = "Install deps")), flush = false)
|
||||
settle()
|
||||
flush()
|
||||
|
||||
assertEquals(0, modelEvents.count { it is SessionModelEvent.ContentAdded })
|
||||
assertEquals(1, modelEvents.count { it is SessionModelEvent.ContentUpdated })
|
||||
val tool = m.model.message("msg1")!!.parts["prt1"] as Tool
|
||||
assertEquals(ToolExecState.COMPLETED, tool.state)
|
||||
assertEquals("Install deps", tool.title)
|
||||
}
|
||||
|
||||
fun `test buffered same part tool updates keep only final busy text`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = Long.MAX_VALUE)
|
||||
val modelEvents = collectModelEvents(m)
|
||||
flush()
|
||||
modelEvents.clear()
|
||||
|
||||
emit(ChatEventDto.TurnOpen("ses_test"))
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
|
||||
modelEvents.clear()
|
||||
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "read", state = "running")), flush = false)
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "running")), flush = false)
|
||||
settle()
|
||||
flush()
|
||||
|
||||
val busy = modelEvents.filterIsInstance<SessionModelEvent.StateChanged>()
|
||||
.filter { it.state is SessionState.Busy }
|
||||
assertEquals(1, busy.size)
|
||||
val state = busy.single().state as SessionState.Busy
|
||||
assertTrue(state.text.contains("commands", ignoreCase = true))
|
||||
}
|
||||
|
||||
fun `test barrier prevents part update merge across turn close`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
val m = controller("ses_test", flushMs = Long.MAX_VALUE)
|
||||
val modelEvents = collectModelEvents(m)
|
||||
flush()
|
||||
modelEvents.clear()
|
||||
|
||||
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
|
||||
emit(ChatEventDto.TurnOpen("ses_test"))
|
||||
modelEvents.clear()
|
||||
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "running")), flush = false)
|
||||
emit(ChatEventDto.TurnClose("ses_test", "completed"), flush = false)
|
||||
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "completed")), flush = false)
|
||||
settle()
|
||||
flush()
|
||||
|
||||
assertModelEvents("""
|
||||
ContentAdded msg1/prt1
|
||||
StateChanged Busy
|
||||
StateChanged Idle
|
||||
ContentUpdated msg1/prt1
|
||||
""", modelEvents)
|
||||
assertEquals(SessionState.Idle, m.model.state)
|
||||
}
|
||||
|
||||
fun `test condensed and raw controller end with same final state on large corpus`() {
|
||||
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
|
||||
projectRpc.state.value = workspaceReady()
|
||||
|
||||
val events = corpus()
|
||||
val condensed = runCorpus(events, true)
|
||||
val raw = runCorpus(events, false)
|
||||
val a = snapshot(condensed)
|
||||
val b = snapshot(raw)
|
||||
|
||||
if (a != b) fail("condensed=\n$a\nraw=\n$b")
|
||||
assertEquals(SessionState.Idle, a.state)
|
||||
assertTrue(a.body.contains("assistant#msg1"))
|
||||
assertTrue(a.body.contains("assistant#msg2"))
|
||||
assertTrue(a.body.contains("diff: src/A.kt src/B.kt"))
|
||||
assertTrue(a.body.contains("todo: [completed] ship feature"))
|
||||
assertEquals(4, a.compacted)
|
||||
}
|
||||
|
||||
private fun corpus(): List<ChatEventDto> = buildList {
|
||||
add(ChatEventDto.TurnOpen("ses_test"))
|
||||
add(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
|
||||
add(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant").copy(cost = 0.01)))
|
||||
add(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant").copy(cost = 0.02)))
|
||||
add(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "read", state = "running")))
|
||||
add(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "read", state = "running", title = "Read files")))
|
||||
add(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "read", state = "completed", title = "Read files")))
|
||||
add(ChatEventDto.PartUpdated("ses_test", part("snap1", "ses_test", "msg1", "text", text = "he")))
|
||||
repeat(8) { i ->
|
||||
add(ChatEventDto.PartDelta("ses_test", "msg1", "txt1", "text", " chunk$i"))
|
||||
}
|
||||
add(ChatEventDto.PartUpdated("ses_test", part("snap1", "ses_test", "msg1", "text", text = "hello")))
|
||||
add(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy")))
|
||||
add(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("retry", message = "retrying", attempt = 2, next = 10L)))
|
||||
add(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("offline", message = "offline", requestID = "req1")))
|
||||
add(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle")))
|
||||
add(ChatEventDto.SessionDiffChanged("ses_test", listOf(DiffFileDto("src/A.kt", 1, 0))))
|
||||
add(ChatEventDto.SessionDiffChanged("ses_test", emptyList()))
|
||||
add(ChatEventDto.SessionDiffChanged("ses_test", listOf(DiffFileDto("src/A.kt", 2, 1), DiffFileDto("src/B.kt", 4, 0))))
|
||||
add(ChatEventDto.TodoUpdated("ses_test", listOf(TodoDto("draft plan", "in_progress", "high"))))
|
||||
add(ChatEventDto.TodoUpdated("ses_test", listOf(TodoDto("ship feature", "completed", "high"))))
|
||||
add(ChatEventDto.SessionCompacted("ses_test"))
|
||||
add(ChatEventDto.MessageUpdated("ses_test", msg("msg2", "ses_test", "assistant")))
|
||||
add(ChatEventDto.MessageUpdated("ses_test", msg("msg2", "ses_test", "assistant").copy(cost = 0.02)))
|
||||
add(ChatEventDto.PartUpdated("ses_test", part("tool2", "ses_test", "msg2", "tool", tool = "edit", state = "running")))
|
||||
add(ChatEventDto.PartUpdated("ses_test", part("tool2", "ses_test", "msg2", "tool", tool = "edit", state = "completed", title = "Patch file")))
|
||||
repeat(6) { i ->
|
||||
add(ChatEventDto.PartDelta("ses_test", "msg2", "txt2", "text", " body$i"))
|
||||
}
|
||||
add(ChatEventDto.TurnClose("ses_test", "completed"))
|
||||
add(ChatEventDto.TurnOpen("ses_test"))
|
||||
add(ChatEventDto.MessageUpdated("ses_test", msg("msg3", "ses_test", "assistant")))
|
||||
add(ChatEventDto.MessageUpdated("ses_test", msg("msg3", "ses_test", "assistant").copy(cost = 0.03)))
|
||||
add(ChatEventDto.PartUpdated("ses_test", part("tail", "ses_test", "msg3", "text", text = "tail start")))
|
||||
repeat(5) { i ->
|
||||
add(ChatEventDto.PartDelta("ses_test", "msg3", "tail", "text", " extra$i"))
|
||||
}
|
||||
add(ChatEventDto.SessionCompacted("ses_test"))
|
||||
add(ChatEventDto.SessionIdle("ses_test"))
|
||||
}
|
||||
|
||||
private fun runCorpus(events: List<ChatEventDto>, condense: Boolean): SessionController {
|
||||
val m = controller("ses_test", flushMs = Long.MAX_VALUE, condense = condense)
|
||||
flush()
|
||||
for (event in events) emit(event, flush = false)
|
||||
settle()
|
||||
flush()
|
||||
return m
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -21,6 +21,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import javax.swing.JPanel
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class QuestionPanelTest : BasePlatformTestCase() {
|
||||
@@ -47,7 +48,8 @@ class QuestionPanelTest : BasePlatformTestCase() {
|
||||
app = KiloAppService(scope, appRpc)
|
||||
workspaces = KiloWorkspaceService(scope, workspaceRpc)
|
||||
workspace = workspaces.workspace("/test")
|
||||
controller = SessionController(parent, "ses_test", sessions, workspace, app, scope)
|
||||
val root = JPanel()
|
||||
controller = SessionController(parent, "ses_test", sessions, workspace, app, scope, root)
|
||||
panel = QuestionPanel(controller)
|
||||
}
|
||||
|
||||
@@ -69,6 +71,8 @@ class QuestionPanelTest : BasePlatformTestCase() {
|
||||
question = "Pick one",
|
||||
header = "Header",
|
||||
options = listOf(QuestionOption("Yes", "desc")),
|
||||
multiple = false,
|
||||
custom = true,
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -80,6 +84,6 @@ class QuestionPanelTest : BasePlatformTestCase() {
|
||||
assertFalse(panel.isVisible)
|
||||
assertEquals(0, panel.componentCount)
|
||||
assertTrue(rpc.questionReplies.isEmpty())
|
||||
assertTrue(rpc.questionRejections.isEmpty())
|
||||
assertTrue(rpc.questionRejects.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# kilo-code
|
||||
|
||||
## 7.2.22
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9444](https://github.com/Kilo-Org/kilocode/pull/9444) [`35f13cb`](https://github.com/Kilo-Org/kilocode/commit/35f13cbc8678f80cf6870c233f74f47b028bf705) - Fix restoring and paginating very long VS Code sessions.
|
||||
|
||||
## 7.2.21
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#9268](https://github.com/Kilo-Org/kilocode/pull/9268) [`48c0553`](https://github.com/Kilo-Org/kilocode/commit/48c0553bb7b8abfa06fb352ad7a9cdc7f1af4bc5) - Open xterm.js-powered terminal tabs in the Agent Manager. Click the chevron next to the `+` tab button and pick "New Terminal" (or press `Cmd+Shift+T` / `Ctrl+Shift+T`) to spawn a real shell in the selected worktree or Local directory. Terminals render as proper tabs alongside agent sessions, support mixed drag-reorder with session tabs, and persist their position across webview reloads. The existing VS Code integrated terminal shortcut (`Cmd+/`) is unchanged.
|
||||
|
||||
- [#9336](https://github.com/Kilo-Org/kilocode/pull/9336) [`85c578e`](https://github.com/Kilo-Org/kilocode/commit/85c578ed844eba7350ce915cff6b4a98f3eb1bbf) - Add the initial JetBrains session chat UI and improve sandbox debug logging for tracing chat events across frontend and backend.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9335](https://github.com/Kilo-Org/kilocode/pull/9335) [`6015ac6`](https://github.com/Kilo-Org/kilocode/commit/6015ac6e7d85fc110a99562674484d3d00167525) - Restore explicit Submit behavior for single-choice question prompts in the VS Code extension so option clicks stay visible for review instead of immediately sending the answer.
|
||||
|
||||
- [#9332](https://github.com/Kilo-Org/kilocode/pull/9332) [`0bda9d1`](https://github.com/Kilo-Org/kilocode/commit/0bda9d15ed5ef99fe149fd680a813ca3b4c1d050) - Fix mid-turn message handling so a new prompt sent while the assistant is working no longer aborts the in-flight response. The current LLM reply streams to completion, any pending suggestion or question is automatically dismissed, and the new prompt runs immediately after the current step instead of waiting for the entire multi-step turn to finish.
|
||||
|
||||
- [#9119](https://github.com/Kilo-Org/kilocode/pull/9119) [`8e75084`](https://github.com/Kilo-Org/kilocode/commit/8e750846da39c6e78478b468b68fdefcaa37f44f) - Fix TUI freeze on huge-file diffs. Session-summary and file-view patches now use git directly instead of a JavaScript Myers implementation, so files of any size render a full diff without blocking the session.
|
||||
|
||||
- [#9341](https://github.com/Kilo-Org/kilocode/pull/9341) [`00ec003`](https://github.com/Kilo-Org/kilocode/commit/00ec003c11476d995556d2b975b4da058f8b958c) - Significantly speed up LLM token streaming in long sessions. The chat view now stays responsive while the model streams a reply, even in sessions with hundreds of messages. Previously, each SSE batch produced ~1.3 seconds of visible freeze (roughly 80 dropped frames); streaming ticks are now inside a single animation frame.
|
||||
|
||||
- Updated dependencies [[`00ec003`](https://github.com/Kilo-Org/kilocode/commit/00ec003c11476d995556d2b975b4da058f8b958c)]:
|
||||
- @opencode-ai/ui@7.2.21
|
||||
- @kilocode/kilo-ui@7.2.21
|
||||
|
||||
## 7.2.19
|
||||
|
||||
## 7.2.18
|
||||
|
||||
@@ -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.20",
|
||||
"version": "7.2.22",
|
||||
"icon": "assets/icons/logo-outline-black.png",
|
||||
"galleryBanner": {
|
||||
"color": "#FFFFFF",
|
||||
|
||||
@@ -54,7 +54,7 @@ import { clearCommandsCache, loadCommands } from "./kilo-provider/commands"
|
||||
import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-page"
|
||||
import { childID } from "./kilo-provider/task-session"
|
||||
import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network"
|
||||
import { abortSession, parseQueued } from "./kilo-provider/abort"
|
||||
import { abortSession } from "./kilo-provider/abort"
|
||||
import {
|
||||
buildAutocompleteSettingsMessage,
|
||||
routeAutocompleteMessage,
|
||||
@@ -622,7 +622,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
case "abort":
|
||||
this.cancelRetry(message.sessionID ?? "")
|
||||
await this.handleAbort(message.sessionID, parseQueued(message.queuedMessageIDs))
|
||||
await this.handleAbort(message.sessionID)
|
||||
break
|
||||
case "revertSession":
|
||||
this.handleRevertSession(message.sessionID, message.messageID).catch((e) =>
|
||||
@@ -2550,7 +2550,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAbort(sessionID?: string, queuedMessageIDs: string[] = []): Promise<void> {
|
||||
private async handleAbort(sessionID?: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
return
|
||||
}
|
||||
@@ -2565,7 +2565,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
client: this.client,
|
||||
sessionID: targetSessionID,
|
||||
dir: this.getWorkspaceDirectory(targetSessionID),
|
||||
queuedMessageIDs,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to abort session:", error)
|
||||
|
||||
@@ -623,7 +623,6 @@ interface ForkSessionIn {
|
||||
interface AbortIn {
|
||||
type: "abort"
|
||||
sessionID: string
|
||||
queuedMessageIDs?: string[]
|
||||
}
|
||||
|
||||
interface ContinueInWorktreeIn {
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
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[]
|
||||
}) {
|
||||
export async function abortSession(input: { client: KiloClient; sessionID: string; dir: 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { retry } from "../services/cli-backend/retry"
|
||||
|
||||
export const MESSAGE_PAGE_LIMIT = 80
|
||||
|
||||
// Bound assistant-boundary backfill so corrupt histories cannot load an entire session.
|
||||
const FILL_LIMIT = 2
|
||||
|
||||
/**
|
||||
* Build the same base64url-encoded cursor format the server emits so a
|
||||
* synthesized cursor round-trips through `session.messages({ before })`.
|
||||
@@ -46,12 +49,13 @@ export async function fetchMessagePage(
|
||||
return { items, cursor }
|
||||
}
|
||||
|
||||
const fill = async (page: Awaited<ReturnType<typeof read>>): Promise<Awaited<ReturnType<typeof read>>> => {
|
||||
const fill = async (page: Awaited<ReturnType<typeof read>>, depth = 0): Promise<Awaited<ReturnType<typeof read>>> => {
|
||||
if (page.items[0]?.info.role !== "assistant") return page
|
||||
if (depth >= FILL_LIMIT) return page
|
||||
if (!page.cursor || input.signal?.aborted) return page
|
||||
const next = await read(page.cursor)
|
||||
const items = [...next.items, ...page.items]
|
||||
return fill({ items, cursor: next.cursor })
|
||||
return fill({ items, cursor: next.cursor }, depth + 1)
|
||||
}
|
||||
|
||||
return fill(await read(input.before))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { abortSession, parseQueued } from "../../src/kilo-provider/abort"
|
||||
import { abortSession } from "../../src/kilo-provider/abort"
|
||||
|
||||
function client(calls: unknown[], fail = false) {
|
||||
return {
|
||||
@@ -10,35 +10,15 @@ function client(calls: unknown[], fail = false) {
|
||||
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 () => {
|
||||
it("calls session.abort with the session id and directory", async () => {
|
||||
const calls: unknown[] = []
|
||||
|
||||
await abortSession({
|
||||
client: client(calls),
|
||||
sessionID: "session_1",
|
||||
dir: "/repo",
|
||||
queuedMessageIDs: ["message_2", "message_3", "message_2"],
|
||||
})
|
||||
await abortSession({ client: client(calls), sessionID: "session_1", dir: "/repo" })
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
@@ -46,30 +26,15 @@ describe("abortSession", () => {
|
||||
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 () => {
|
||||
it("rejects when the abort request fails", async () => {
|
||||
const calls: unknown[] = []
|
||||
|
||||
await expect(
|
||||
abortSession({
|
||||
client: client(calls, true),
|
||||
sessionID: "session_1",
|
||||
dir: "/repo",
|
||||
queuedMessageIDs: ["message_2"],
|
||||
}),
|
||||
).rejects.toThrow("abort failed")
|
||||
await expect(abortSession({ client: client(calls, true), sessionID: "session_1", dir: "/repo" })).rejects.toThrow(
|
||||
"abort failed",
|
||||
)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
|
||||
@@ -14,7 +14,9 @@ import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "../..")
|
||||
const MESSAGES_FILE = path.join(ROOT, "webview-ui/src/types/messages.ts")
|
||||
const MESSAGES_DIR = path.join(ROOT, "webview-ui/src/types/messages")
|
||||
const EXTENSION_MESSAGES_FILE = path.join(MESSAGES_DIR, "extension-messages.ts")
|
||||
const WEBVIEW_MESSAGES_FILE = path.join(MESSAGES_DIR, "webview-messages.ts")
|
||||
const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
|
||||
const KILO_PROVIDER_UTILS_FILE = path.join(ROOT, "src/kilo-provider-utils.ts")
|
||||
// Some wire types (partUpdated, partsUpdated) live in a file shared by the
|
||||
@@ -25,52 +27,68 @@ function readFile(filePath: string): string {
|
||||
return fs.readFileSync(filePath, "utf-8")
|
||||
}
|
||||
|
||||
function readMessagesDir(): string {
|
||||
return fs
|
||||
.readdirSync(MESSAGES_DIR)
|
||||
.filter((f) => f.endsWith(".ts"))
|
||||
.map((f) => readFile(path.join(MESSAGES_DIR, f)))
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function readMessageTypeSources(): string {
|
||||
return readFile(MESSAGES_FILE) + "\n" + readFile(SHARED_STREAM_MESSAGES_FILE)
|
||||
return readMessagesDir() + "\n" + readFile(SHARED_STREAM_MESSAGES_FILE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the named union's member names from a single source file.
|
||||
*
|
||||
* Reads each ` | MemberName` line after `export type Name =`, skipping
|
||||
* blank lines and `//` comments, and stops at the first other line.
|
||||
*/
|
||||
function extractUnionMembers(src: string, name: string): string[] {
|
||||
const lines = src.split("\n")
|
||||
const start = lines.findIndex((l) => new RegExp(`^export type ${name}\\s*=\\s*$`).test(l))
|
||||
if (start === -1) throw new Error(`Could not find union "${name}"`)
|
||||
const members: string[] = []
|
||||
for (const line of lines.slice(start + 1)) {
|
||||
const m = line.match(/^\s*\|\s*([A-Z]\w+)\b/)
|
||||
if (m) {
|
||||
members.push(m[1]!)
|
||||
continue
|
||||
}
|
||||
if (line.trim() === "" || /^\s*\/\//.test(line)) continue
|
||||
break
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
describe("ExtensionMessage type members", () => {
|
||||
it("all members of ExtensionMessage union are defined as interfaces/types in messages.ts", () => {
|
||||
const content = readFile(MESSAGES_FILE)
|
||||
|
||||
// Extract ExtensionMessage union members
|
||||
const unionMatch = content.match(
|
||||
/export type ExtensionMessage\s*=\s*([\s\S]*?)(?=\nexport type|\nexport interface|\nexport function|\n\/\/|$)/,
|
||||
)
|
||||
if (!unionMatch) {
|
||||
expect(false, "Could not find ExtensionMessage union in messages.ts").toBe(true)
|
||||
return
|
||||
}
|
||||
|
||||
const unionBody = unionMatch[1]!
|
||||
const memberNames = [...unionBody.matchAll(/\|\s*([A-Z]\w+)\b/g)].map((m) => m[1]!)
|
||||
it("all members of ExtensionMessage union are defined in message type sources", () => {
|
||||
const memberNames = extractUnionMembers(readFile(EXTENSION_MESSAGES_FILE), "ExtensionMessage")
|
||||
|
||||
const defined = readMessageTypeSources()
|
||||
const missing = memberNames.filter((name) => {
|
||||
return !new RegExp(`(interface|type)\\s+${name}\\b`).test(defined)
|
||||
})
|
||||
|
||||
expect(missing, `ExtensionMessage members without definitions: ${missing.join(", ")}`).toEqual([])
|
||||
expect(
|
||||
missing,
|
||||
`ExtensionMessage members without definitions in message type sources: ${missing.join(", ")}`,
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it("all members of WebviewMessage union are defined as interfaces/types in messages.ts", () => {
|
||||
const content = readFile(MESSAGES_FILE)
|
||||
|
||||
const unionMatch = content.match(/export type WebviewMessage\s*=\s*([\s\S]*?)(?=\n\/\/|$)/)
|
||||
if (!unionMatch) {
|
||||
expect(false, "Could not find WebviewMessage union in messages.ts").toBe(true)
|
||||
return
|
||||
}
|
||||
|
||||
const unionBody = unionMatch[1]!
|
||||
const memberNames = [...unionBody.matchAll(/\|\s*([A-Z]\w+)\b/g)].map((m) => m[1]!)
|
||||
it("all members of WebviewMessage union are defined in message type sources", () => {
|
||||
const memberNames = extractUnionMembers(readFile(WEBVIEW_MESSAGES_FILE), "WebviewMessage")
|
||||
|
||||
const defined = readMessageTypeSources()
|
||||
const missing = memberNames.filter((name) => {
|
||||
return !new RegExp(`(interface|type)\\s+${name}\\b`).test(defined)
|
||||
})
|
||||
|
||||
expect(missing, `WebviewMessage members without definitions: ${missing.join(", ")}`).toEqual([])
|
||||
expect(
|
||||
missing,
|
||||
`WebviewMessage members without definitions in message type sources: ${missing.join(", ")}`,
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,6 +127,9 @@ describe("mapSSEEventToWebviewMessage output types", () => {
|
||||
|
||||
const missing = typeMatches.filter((t) => !typeSet.has(t))
|
||||
|
||||
expect(missing, `Types in mapSSEEventToWebviewMessage not in messages.ts: ${missing.join(", ")}`).toEqual([])
|
||||
expect(
|
||||
missing,
|
||||
`Types in mapSSEEventToWebviewMessage not found in message type sources: ${missing.join(", ")}`,
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -164,4 +164,34 @@ describe("fetchMessagePage / cursor fallback", () => {
|
||||
expect(page.items.map((item) => item.info.id)).toEqual(["m1", "m2", "m3", "m4", "m5"])
|
||||
expect(page.cursor).toBeUndefined()
|
||||
})
|
||||
|
||||
it("bounds assistant turn filling when older pages never reach a user message", async () => {
|
||||
const { client, calls } = mockClient([
|
||||
{
|
||||
items: [message("m5", "assistant", 50), message("m6", "assistant", 60)],
|
||||
cursor: "c1",
|
||||
},
|
||||
{
|
||||
items: [message("m3", "assistant", 30), message("m4", "assistant", 40)],
|
||||
cursor: "c2",
|
||||
},
|
||||
{
|
||||
items: [message("m1", "assistant", 10), message("m2", "assistant", 20)],
|
||||
cursor: "c3",
|
||||
},
|
||||
{
|
||||
items: [message("m0", "user", 0)],
|
||||
},
|
||||
])
|
||||
|
||||
const page = await fetchMessagePage(client as never, {
|
||||
sessionID: "s1",
|
||||
workspaceDir: "/repo",
|
||||
limit: 2,
|
||||
})
|
||||
|
||||
expect(calls.map((call) => call.before)).toEqual([undefined, "c1", "c2"])
|
||||
expect(page.items.map((item) => item.info.id)).toEqual(["m1", "m2", "m3", "m4", "m5", "m6"])
|
||||
expect(page.cursor).toBe("c3")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { activeUserMessageID, messageTurns, queuedUserMessageIDs } from "../../webview-ui/src/context/session-queue"
|
||||
import {
|
||||
activeUserMessageID,
|
||||
messageTurns,
|
||||
queuedUserMessageIDs,
|
||||
stableMessageTurns,
|
||||
} from "../../webview-ui/src/context/session-queue"
|
||||
import type { Message } from "../../webview-ui/src/types/messages"
|
||||
|
||||
const base = {
|
||||
@@ -84,6 +89,56 @@ describe("messageTurns", () => {
|
||||
{ user: "message_4", assistant: [] },
|
||||
])
|
||||
})
|
||||
|
||||
it("surfaces leading assistant output as partial turns grouped by parent", () => {
|
||||
const messages = [
|
||||
assistant("message_2", "message_1"),
|
||||
assistant("message_4", "message_3"),
|
||||
assistant("message_5", "message_3"),
|
||||
user("message_6"),
|
||||
]
|
||||
const turns = messageTurns(messages)
|
||||
|
||||
expect(
|
||||
turns.map((turn) => ({ id: turn.id, partial: turn.partial, assistant: turn.assistant.map((msg) => msg.id) })),
|
||||
).toEqual([
|
||||
{ id: "message_1", partial: true, assistant: ["message_2"] },
|
||||
{ id: "message_3", partial: true, assistant: ["message_4", "message_5"] },
|
||||
{ id: "message_6", partial: undefined, assistant: [] },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("stableMessageTurns", () => {
|
||||
it("keeps existing turn identities stable when older turns are prepended", () => {
|
||||
const u1 = user("message_1")
|
||||
const a2 = assistant("message_2", "message_1")
|
||||
const u3 = user("message_3")
|
||||
const prev = messageTurns([u1, a2, u3])
|
||||
const next = stableMessageTurns(messageTurns([user("message_0"), u1, a2, u3]), prev)
|
||||
|
||||
expect(next[1]).toBe(prev[0])
|
||||
expect(next[2]).toBe(prev[1])
|
||||
})
|
||||
|
||||
it("replaces a turn identity when its assistant messages change", () => {
|
||||
const u1 = user("message_1")
|
||||
const a2 = assistant("message_2", "message_1")
|
||||
const prev = messageTurns([u1, a2])
|
||||
const next = stableMessageTurns(messageTurns([u1, a2, assistant("message_3", "message_1")]), prev)
|
||||
|
||||
expect(next[0]).not.toBe(prev[0])
|
||||
expect(next[0]?.assistant.map((msg) => msg.id)).toEqual(["message_2", "message_3"])
|
||||
})
|
||||
|
||||
it("keeps partial turn identities stable while their assistant messages are unchanged", () => {
|
||||
const a2 = assistant("message_2", "message_1")
|
||||
const a3 = assistant("message_3", "message_1")
|
||||
const prev = messageTurns([a2, a3])
|
||||
const next = stableMessageTurns(messageTurns([a2, a3, user("message_4")]), prev)
|
||||
|
||||
expect(next[0]).toBe(prev[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe("activeUserMessageID", () => {
|
||||
|
||||
@@ -381,10 +381,10 @@ describe("constants", () => {
|
||||
})
|
||||
|
||||
it("KNOWN_KEYS matches all keys in the Config interface (drift guard)", async () => {
|
||||
// Read the Config interface from messages.ts and extract its keys.
|
||||
// Read the Config interface from messages/config.ts and extract its keys.
|
||||
// If someone adds a new field to Config, this test fails as a reminder
|
||||
// to also add it to KNOWN_KEYS in settings-io.ts.
|
||||
const src = await Bun.file(require("path").join(__dirname, "../../webview-ui/src/types/messages.ts")).text()
|
||||
const src = await Bun.file(require("path").join(__dirname, "../../webview-ui/src/types/messages/config.ts")).text()
|
||||
const match = src.match(/export interface Config \{([^}]+)\}/)
|
||||
expect(match).not.toBeNull()
|
||||
const body = match![1]
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
activeUserMessageID as getActiveUserMessageID,
|
||||
messageTurns,
|
||||
queuedUserMessageIDs,
|
||||
stableMessageTurns,
|
||||
type MessageTurn,
|
||||
} from "../../context/session-queue"
|
||||
import type { QuestionRequest, SuggestionRequest } from "../../types/messages"
|
||||
|
||||
@@ -84,7 +86,9 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
const positions = new Map<string, { top: number; userScrolled: boolean }>()
|
||||
|
||||
const boundary = () => session.revert()?.messageID
|
||||
const turns = createMemo(() => messageTurns(session.messages(), boundary()))
|
||||
const turns = createMemo((prev: MessageTurn[] | undefined) =>
|
||||
stableMessageTurns(messageTurns(session.messages(), boundary()), prev),
|
||||
)
|
||||
const isEmpty = () => turns().length === 0 && !session.loading() && !boundary()
|
||||
|
||||
const recent = createMemo(() =>
|
||||
@@ -151,11 +155,11 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
if (pos?.userScrolled) {
|
||||
el.scrollTop = pos.top
|
||||
autoScroll.pause()
|
||||
maybeLoadOlder()
|
||||
} else {
|
||||
autoScroll.forceScrollToBottom()
|
||||
}
|
||||
setPendingRestore(undefined)
|
||||
maybeLoadOlder()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface VscodeTurn {
|
||||
id: string
|
||||
user: WebMessage
|
||||
assistant: WebMessage[]
|
||||
partial?: boolean
|
||||
}
|
||||
|
||||
interface VscodeSessionTurnProps {
|
||||
@@ -71,7 +72,8 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
|
||||
|
||||
createEffect(() => {
|
||||
const turn = props.turn
|
||||
session.hydrateParts([turn.user.id, ...turn.assistant.map((m) => m.id)])
|
||||
const ids = turn.partial ? turn.assistant.map((m) => m.id) : [turn.user.id, ...turn.assistant.map((m) => m.id)]
|
||||
session.hydrateParts(ids)
|
||||
})
|
||||
|
||||
const message = createMemo(() => props.turn.user as SDKMessage & { role: "user" })
|
||||
@@ -138,33 +140,35 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
|
||||
{(msg) => (
|
||||
<div class="vscode-session-turn" data-message={msg().id}>
|
||||
{/* User message */}
|
||||
<div
|
||||
class="vscode-session-turn-user"
|
||||
data-revert-disabled={
|
||||
assistantMessages().length > 0 && !session.revert() && session.status() !== "idle" ? "" : undefined
|
||||
}
|
||||
title={
|
||||
assistantMessages().length > 0 && !session.revert() && session.status() !== "idle"
|
||||
? language.t("revert.disabled.agentBusy")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<UserMessageDisplay
|
||||
message={msg() as unknown as Parameters<typeof UserMessageDisplay>[0]["message"]}
|
||||
parts={parts() as unknown as Parameters<typeof UserMessageDisplay>[0]["parts"]}
|
||||
interrupted={interrupted()}
|
||||
queued={props.queued}
|
||||
onFork={props.onForkMessage ? () => props.onForkMessage?.(msg().sessionID, msg().id) : undefined}
|
||||
onRevert={
|
||||
assistantMessages().length > 0 && !session.revert()
|
||||
? () => {
|
||||
if (session.status() !== "idle") return
|
||||
session.revertSession(msg().id)
|
||||
}
|
||||
<Show when={!props.turn.partial}>
|
||||
<div
|
||||
class="vscode-session-turn-user"
|
||||
data-revert-disabled={
|
||||
assistantMessages().length > 0 && !session.revert() && session.status() !== "idle" ? "" : undefined
|
||||
}
|
||||
title={
|
||||
assistantMessages().length > 0 && !session.revert() && session.status() !== "idle"
|
||||
? language.t("revert.disabled.agentBusy")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<UserMessageDisplay
|
||||
message={msg() as unknown as Parameters<typeof UserMessageDisplay>[0]["message"]}
|
||||
parts={parts() as unknown as Parameters<typeof UserMessageDisplay>[0]["parts"]}
|
||||
interrupted={interrupted()}
|
||||
queued={props.queued}
|
||||
onFork={props.onForkMessage ? () => props.onForkMessage?.(msg().sessionID, msg().id) : undefined}
|
||||
onRevert={
|
||||
assistantMessages().length > 0 && !session.revert()
|
||||
? () => {
|
||||
if (session.status() !== "idle") return
|
||||
session.revertSession(msg().id)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Assistant parts — flat list, no context grouping */}
|
||||
<Show when={assistantMessages().length > 0}>
|
||||
|
||||
@@ -4,10 +4,47 @@ export interface MessageTurn {
|
||||
id: string
|
||||
user: Message
|
||||
assistant: Message[]
|
||||
partial?: boolean
|
||||
}
|
||||
|
||||
export function messageTurns(messages: Message[], boundary?: string) {
|
||||
function key(msg: Message) {
|
||||
return msg.parentID ?? msg.id
|
||||
}
|
||||
|
||||
function partial(messages: Message[]): MessageTurn {
|
||||
const first = messages[0]!
|
||||
const id = first.parentID ?? `${first.id}:partial`
|
||||
return {
|
||||
id,
|
||||
user: {
|
||||
id,
|
||||
sessionID: first.sessionID,
|
||||
role: "user",
|
||||
createdAt: first.createdAt,
|
||||
time: first.time,
|
||||
},
|
||||
assistant: messages,
|
||||
partial: true,
|
||||
}
|
||||
}
|
||||
|
||||
function partials(messages: Message[]): MessageTurn[] {
|
||||
return messages
|
||||
.reduce<Message[][]>((groups, msg) => {
|
||||
const prev = groups[groups.length - 1]
|
||||
if (!prev || key(prev[0]!) !== key(msg)) {
|
||||
groups.push([msg])
|
||||
return groups
|
||||
}
|
||||
prev.push(msg)
|
||||
return groups
|
||||
}, [])
|
||||
.map(partial)
|
||||
}
|
||||
|
||||
export function messageTurns(messages: Message[], boundary?: string): MessageTurn[] {
|
||||
const result: MessageTurn[] = []
|
||||
const lead: Message[] = []
|
||||
const by = new Map<string, MessageTurn>()
|
||||
|
||||
for (const msg of messages) {
|
||||
@@ -20,11 +57,43 @@ export function messageTurns(messages: Message[], boundary?: string) {
|
||||
}
|
||||
|
||||
if (msg.role !== "assistant") continue
|
||||
const turn = (msg.parentID ? by.get(msg.parentID) : undefined) ?? result[result.length - 1]
|
||||
if (turn) turn.assistant.push(msg)
|
||||
const turn = msg.parentID ? by.get(msg.parentID) : undefined
|
||||
if (turn) {
|
||||
turn.assistant.push(msg)
|
||||
continue
|
||||
}
|
||||
const last = result[result.length - 1]
|
||||
if (last) {
|
||||
last.assistant.push(msg)
|
||||
continue
|
||||
}
|
||||
lead.push(msg)
|
||||
}
|
||||
|
||||
return result
|
||||
if (lead.length === 0) return result
|
||||
return [...partials(lead), ...result]
|
||||
}
|
||||
|
||||
function sameMessages(a: Message[], b: Message[]) {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Keep virtua's item keys stable across prepends; Solid's adapter keys by data object identity.
|
||||
export function stableMessageTurns(next: MessageTurn[], prev: MessageTurn[] = []): MessageTurn[] {
|
||||
if (prev.length === 0) return next
|
||||
const by = new Map(prev.map((turn) => [turn.user.id, turn]))
|
||||
return next.map((turn) => {
|
||||
const old = by.get(turn.user.id)
|
||||
if (!old) return turn
|
||||
if (old.partial !== turn.partial) return turn
|
||||
if (!turn.partial && old.user !== turn.user) return turn
|
||||
if (!sameMessages(old.assistant, turn.assistant)) return turn
|
||||
return old
|
||||
})
|
||||
}
|
||||
|
||||
function active(messages: Message[]) {
|
||||
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
import { Identifier } from "../utils/id"
|
||||
import { resolveModelSelection } from "./model-selection"
|
||||
import { resolveSessionAgent } from "./session-agent"
|
||||
import { queuedUserMessageIDs } from "./session-queue"
|
||||
import { PartStash } from "./part-stash"
|
||||
import { KILO_AUTO, parseModelString } from "../../../src/shared/provider-model"
|
||||
|
||||
@@ -1748,12 +1747,9 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return
|
||||
}
|
||||
|
||||
const queuedMessageIDs = queuedUserMessageIDs(messages(), statusInfo())
|
||||
|
||||
vscode.postMessage({
|
||||
type: "abort",
|
||||
sessionID,
|
||||
queuedMessageIDs,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
export type WorktreeErrorCode = "git_not_found" | "not_git_repo" | "lfs_missing"
|
||||
|
||||
// Agent Manager worktree state types (mirrored from WorktreeStateManager)
|
||||
export interface WorktreeState {
|
||||
id: string
|
||||
branch: string
|
||||
path: string
|
||||
/** Bare branch name (e.g. "main"), without remote prefix. */
|
||||
parentBranch: string
|
||||
/** Remote name (e.g. "origin"). */
|
||||
remote?: string
|
||||
createdAt: string
|
||||
/** Shared identifier for worktrees created together via multi-version mode. */
|
||||
groupId?: string
|
||||
/** User-provided display name for the worktree. */
|
||||
label?: string
|
||||
/** Cached PR number for instant badge display on reload. */
|
||||
prNumber?: number
|
||||
/** Cached PR URL for instant badge display on reload. */
|
||||
prUrl?: string
|
||||
/** Cached PR state for correct badge color on reload (open/merged/closed/draft). */
|
||||
prState?: string
|
||||
/** Section this worktree belongs to, or undefined for ungrouped. */
|
||||
sectionId?: string
|
||||
}
|
||||
|
||||
export interface SectionState {
|
||||
id: string
|
||||
name: string
|
||||
/** Color label (e.g. "Red", "Blue") or null for default. */
|
||||
color: string | null
|
||||
order: number
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PR status types (mirrored from extension types.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PRState = "open" | "draft" | "merged" | "closed"
|
||||
export type ReviewDecision = "approved" | "changes_requested" | "pending"
|
||||
export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled"
|
||||
export type AggregateCheckStatus = "success" | "failure" | "pending" | "none"
|
||||
|
||||
export interface PRCheck {
|
||||
name: string
|
||||
status: CheckStatus
|
||||
url?: string
|
||||
duration?: string
|
||||
}
|
||||
|
||||
export interface PRComment {
|
||||
id: string
|
||||
author: string
|
||||
avatar?: string
|
||||
body: string
|
||||
file?: string
|
||||
line?: number
|
||||
url?: string
|
||||
resolved: boolean
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
export interface PRStatus {
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
state: PRState
|
||||
review: ReviewDecision | null
|
||||
checks: {
|
||||
status: AggregateCheckStatus
|
||||
total: number
|
||||
passed: number
|
||||
failed: number
|
||||
pending: number
|
||||
items: PRCheck[]
|
||||
}
|
||||
comments?: {
|
||||
total: number
|
||||
unresolved: number
|
||||
items: PRComment[]
|
||||
}
|
||||
additions: number
|
||||
deletions: number
|
||||
files: number
|
||||
}
|
||||
|
||||
export type RunState = "idle" | "running" | "stopping"
|
||||
|
||||
export interface RunStatus {
|
||||
worktreeId: string
|
||||
state: RunState
|
||||
exitCode?: number
|
||||
signal?: string
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ManagedSessionState {
|
||||
id: string
|
||||
worktreeId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface BranchInfo {
|
||||
name: string
|
||||
isLocal: boolean
|
||||
isRemote: boolean
|
||||
isDefault: boolean
|
||||
lastCommitDate?: string
|
||||
isCheckedOut?: boolean
|
||||
}
|
||||
|
||||
// Agent Manager Import tab: external worktrees (extension → webview)
|
||||
export interface ExternalWorktreeInfo {
|
||||
path: string
|
||||
branch: string
|
||||
}
|
||||
|
||||
// Shared FileDiff shape (matches Snapshot.FileDiff from CLI backend)
|
||||
export interface WorktreeFileDiff {
|
||||
file: string
|
||||
before: string
|
||||
after: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
tracked?: boolean
|
||||
generatedLike?: boolean
|
||||
summarized?: boolean
|
||||
stamp?: string
|
||||
}
|
||||
|
||||
export type AgentManagerApplyWorktreeDiffStatus = "checking" | "applying" | "success" | "conflict" | "error"
|
||||
|
||||
export interface AgentManagerApplyWorktreeDiffConflict {
|
||||
file?: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
// Per-worktree git stats: diff additions/deletions and ahead/behind counts
|
||||
export interface WorktreeGitStats {
|
||||
worktreeId: string
|
||||
files: number
|
||||
additions: number
|
||||
deletions: number
|
||||
ahead: number
|
||||
behind: number
|
||||
}
|
||||
|
||||
// Per-local-workspace git stats: branch name, diff additions/deletions, ahead/behind counts
|
||||
export interface LocalGitStats {
|
||||
branch: string
|
||||
files: number
|
||||
additions: number
|
||||
deletions: number
|
||||
ahead: number
|
||||
behind: number
|
||||
}
|
||||
|
||||
export interface ReviewComment {
|
||||
id: string
|
||||
file: string
|
||||
side: "additions" | "deletions"
|
||||
line: number
|
||||
comment: string
|
||||
selectedText: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum number of parallel worktree versions for multi-version mode.
|
||||
* Keep in sync with MAX_MULTI_VERSIONS in src/agent-manager/constants.ts.
|
||||
*/
|
||||
export const MAX_MULTI_VERSIONS = 4
|
||||
|
||||
// Per-version model allocation for multi-model comparison mode
|
||||
export interface ModelAllocation {
|
||||
providerID: string
|
||||
modelID: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export type ContinueInWorktreeStatus =
|
||||
| "capturing"
|
||||
| "creating"
|
||||
| "setup"
|
||||
| "transferring"
|
||||
| "forking"
|
||||
| "done"
|
||||
| "error"
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PermissionConfig, PermissionRuleItem } from "./permissions"
|
||||
|
||||
// Skill info from CLI backend
|
||||
export interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
location: string
|
||||
}
|
||||
|
||||
// Slash command info from CLI backend
|
||||
export interface SlashCommandInfo {
|
||||
name: string
|
||||
description?: string
|
||||
source?: "command" | "mcp" | "skill"
|
||||
hints: string[]
|
||||
}
|
||||
|
||||
// Agent/mode info from CLI backend
|
||||
export interface AgentInfo {
|
||||
name: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
mode: "subagent" | "primary" | "all"
|
||||
native?: boolean
|
||||
hidden?: boolean
|
||||
deprecated?: boolean
|
||||
color?: string
|
||||
permission?: PermissionRuleItem[]
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
model?: string | null
|
||||
prompt?: string
|
||||
description?: string
|
||||
mode?: "subagent" | "primary" | "all"
|
||||
hidden?: boolean
|
||||
disable?: boolean
|
||||
temperature?: number
|
||||
top_p?: number
|
||||
steps?: number
|
||||
permission?: PermissionConfig
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { PermissionConfig } from "./permissions"
|
||||
import type { AgentConfig } from "./agents"
|
||||
import type { ProviderConfig } from "./providers"
|
||||
|
||||
export interface McpConfig {
|
||||
type?: "local" | "remote"
|
||||
command?: string[] | string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
environment?: Record<string, string>
|
||||
url?: string
|
||||
headers?: Record<string, string>
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface CommandConfig {
|
||||
template: string
|
||||
description?: string
|
||||
agent?: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface SkillsConfig {
|
||||
paths?: string[]
|
||||
urls?: string[]
|
||||
}
|
||||
|
||||
export interface CompactionConfig {
|
||||
auto?: boolean
|
||||
prune?: boolean
|
||||
}
|
||||
|
||||
export interface WatcherConfig {
|
||||
ignore?: string[]
|
||||
}
|
||||
|
||||
export interface ExperimentalConfig {
|
||||
disable_paste_summary?: boolean
|
||||
batch_tool?: boolean
|
||||
codebase_search?: boolean
|
||||
primary_tools?: string[]
|
||||
continue_loop_on_deny?: boolean
|
||||
mcp_timeout?: number
|
||||
}
|
||||
|
||||
export interface CommitMessageConfig {
|
||||
prompt?: string
|
||||
}
|
||||
|
||||
export interface BrowserSettings {
|
||||
enabled: boolean
|
||||
useSystemChrome: boolean
|
||||
headless: boolean
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
permission?: PermissionConfig
|
||||
model?: string | null
|
||||
small_model?: string | null
|
||||
default_agent?: string
|
||||
agent?: Record<string, AgentConfig>
|
||||
provider?: Record<string, ProviderConfig>
|
||||
disabled_providers?: string[]
|
||||
enabled_providers?: string[]
|
||||
mcp?: Record<string, McpConfig>
|
||||
command?: Record<string, CommandConfig>
|
||||
instructions?: string[]
|
||||
skills?: SkillsConfig
|
||||
snapshot?: boolean
|
||||
remote_control?: boolean
|
||||
share?: "manual" | "auto" | "disabled"
|
||||
username?: string
|
||||
watcher?: WatcherConfig
|
||||
formatter?: false | Record<string, unknown>
|
||||
lsp?: false | Record<string, unknown>
|
||||
compaction?: CompactionConfig
|
||||
commit_message?: CommitMessageConfig
|
||||
tools?: Record<string, boolean>
|
||||
layout?: "auto" | "stretch"
|
||||
experimental?: ExperimentalConfig
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Connection states
|
||||
export type ConnectionState = "connecting" | "connected" | "disconnected" | "error"
|
||||
|
||||
// Session status (simplified from backend)
|
||||
export type SessionStatus = "idle" | "busy" | "retry" | "offline"
|
||||
|
||||
// Rich status info for retry countdown and future extensions
|
||||
export type SessionStatusInfo =
|
||||
| { type: "idle" }
|
||||
| { type: "busy" }
|
||||
| { type: "retry"; attempt: number; message: string; next: number }
|
||||
| { type: "offline"; message: string }
|
||||
|
||||
// Server info
|
||||
export interface ServerInfo {
|
||||
port: number
|
||||
version?: string
|
||||
}
|
||||
|
||||
// Device auth flow status
|
||||
export type DeviceAuthStatus = "idle" | "initiating" | "pending" | "success" | "error" | "cancelled"
|
||||
|
||||
// Device auth state
|
||||
export interface DeviceAuthState {
|
||||
status: DeviceAuthStatus
|
||||
code?: string
|
||||
verificationUrl?: string
|
||||
expiresIn?: number
|
||||
error?: string
|
||||
}
|
||||
@@ -0,0 +1,906 @@
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@kilocode/sdk/v2/client"
|
||||
import type { PartBatch, PartUpdate } from "../../../../src/shared/stream-messages"
|
||||
import type { SessionMode } from "../../context/worktree-mode"
|
||||
import type { MarketplaceItem, MarketplaceInstalledMetadata } from "../marketplace"
|
||||
import type { ConnectionState, ServerInfo, SessionStatus } from "./connection"
|
||||
import type { FileAttachment, Part } from "./parts"
|
||||
import type { CloudSessionInfo, Message, MessageLoadMode, SessionInfo } from "./sessions"
|
||||
import type { PermissionRequest } from "./permissions"
|
||||
import type { QuestionRequest, SuggestionRequest, TodoItem } from "./questions"
|
||||
import type { ModelSelection, Provider, ProviderAuthState } from "./providers"
|
||||
import type { AgentInfo, SkillInfo, SlashCommandInfo } from "./agents"
|
||||
import type { BrowserSettings, Config } from "./config"
|
||||
import type { KilocodeNotification, ProfileData } from "./profile"
|
||||
import type {
|
||||
AgentManagerApplyWorktreeDiffConflict,
|
||||
AgentManagerApplyWorktreeDiffStatus,
|
||||
BranchInfo,
|
||||
ContinueInWorktreeStatus,
|
||||
ExternalWorktreeInfo,
|
||||
LocalGitStats,
|
||||
ManagedSessionState,
|
||||
PRStatus,
|
||||
ReviewComment,
|
||||
RunStatus,
|
||||
SectionState,
|
||||
WorktreeErrorCode,
|
||||
WorktreeFileDiff,
|
||||
WorktreeGitStats,
|
||||
WorktreeState,
|
||||
} from "./agent-manager"
|
||||
import type {
|
||||
LegacyMigrationCompleteMessage,
|
||||
LegacyMigrationDataMessage,
|
||||
LegacyMigrationProgressMessage,
|
||||
LegacyMigrationSessionProgressMessage,
|
||||
MigrationStateMessage,
|
||||
} from "./migration"
|
||||
|
||||
// ============================================
|
||||
// Messages FROM extension TO webview
|
||||
// ============================================
|
||||
|
||||
export interface ReadyMessage {
|
||||
type: "ready"
|
||||
serverInfo?: ServerInfo
|
||||
extensionVersion?: string
|
||||
vscodeLanguage?: string
|
||||
languageOverride?: string
|
||||
workspaceDirectory?: string
|
||||
}
|
||||
|
||||
export interface GitStatusMessage {
|
||||
type: "gitStatus"
|
||||
repo: boolean
|
||||
}
|
||||
|
||||
export interface WorkspaceDirectoryChangedMessage {
|
||||
type: "workspaceDirectoryChanged"
|
||||
directory: string
|
||||
}
|
||||
|
||||
export interface LanguageChangedMessage {
|
||||
type: "languageChanged"
|
||||
locale: string
|
||||
}
|
||||
|
||||
export interface ConnectionStateMessage {
|
||||
type: "connectionState"
|
||||
state: ConnectionState
|
||||
error?: string
|
||||
userMessage?: string
|
||||
userDetails?: string
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
type: "error"
|
||||
message: string
|
||||
code?: string
|
||||
sessionID?: string
|
||||
}
|
||||
|
||||
export interface SendMessageFailedMessage {
|
||||
type: "sendMessageFailed"
|
||||
error: string
|
||||
text: string
|
||||
sessionID?: string
|
||||
draftID?: string
|
||||
messageID?: string
|
||||
files?: FileAttachment[]
|
||||
}
|
||||
|
||||
// Wire shape lives in src/shared/stream-messages.ts; narrow `part` to the
|
||||
// webview's concrete union.
|
||||
export type PartUpdatedMessage = PartUpdate<Part>
|
||||
export type PartsUpdatedMessage = PartBatch<Part>
|
||||
|
||||
export interface SessionStatusMessage {
|
||||
type: "sessionStatus"
|
||||
sessionID: string
|
||||
status: SessionStatus
|
||||
// Retry fields (present when status === "retry")
|
||||
attempt?: number
|
||||
message?: string
|
||||
next?: number
|
||||
}
|
||||
|
||||
export interface SessionErrorMessage {
|
||||
type: "sessionError"
|
||||
sessionID?: string
|
||||
error?: { name: string; data?: Record<string, unknown> }
|
||||
}
|
||||
|
||||
export interface PermissionRequestMessage {
|
||||
type: "permissionRequest"
|
||||
permission: PermissionRequest
|
||||
}
|
||||
|
||||
export interface PermissionResolvedMessage {
|
||||
type: "permissionResolved"
|
||||
permissionID: string
|
||||
}
|
||||
|
||||
export interface PermissionErrorMessage {
|
||||
type: "permissionError"
|
||||
permissionID: string
|
||||
}
|
||||
|
||||
export interface TodoUpdatedMessage {
|
||||
type: "todoUpdated"
|
||||
sessionID: string
|
||||
items: TodoItem[]
|
||||
}
|
||||
|
||||
export interface SessionCreatedMessage {
|
||||
type: "sessionCreated"
|
||||
session: SessionInfo
|
||||
draftID?: string
|
||||
}
|
||||
|
||||
export interface SessionForkedMessage {
|
||||
type: "sessionForked"
|
||||
sessionID: string
|
||||
}
|
||||
|
||||
export interface SessionUpdatedMessage {
|
||||
type: "sessionUpdated"
|
||||
session: SessionInfo
|
||||
}
|
||||
|
||||
export interface SessionDeletedMessage {
|
||||
type: "sessionDeleted"
|
||||
sessionID: string
|
||||
}
|
||||
|
||||
export interface MessageRemovedMessage {
|
||||
type: "messageRemoved"
|
||||
sessionID: string
|
||||
messageID: string
|
||||
}
|
||||
|
||||
export interface MessagesLoadedMessage {
|
||||
type: "messagesLoaded"
|
||||
sessionID: string
|
||||
messages: Message[]
|
||||
mode?: Exclude<MessageLoadMode, "focus">
|
||||
cursor?: string
|
||||
hasMore?: boolean
|
||||
}
|
||||
|
||||
export interface MessageCreatedMessage {
|
||||
type: "messageCreated"
|
||||
message: Message
|
||||
}
|
||||
|
||||
export interface SessionsLoadedMessage {
|
||||
type: "sessionsLoaded"
|
||||
sessions: SessionInfo[]
|
||||
preserveSessionIds?: string[]
|
||||
}
|
||||
|
||||
export interface CloudSessionsLoadedMessage {
|
||||
type: "cloudSessionsLoaded"
|
||||
sessions: CloudSessionInfo[]
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
export interface GitRemoteUrlLoadedMessage {
|
||||
type: "gitRemoteUrlLoaded"
|
||||
gitUrl: string | null
|
||||
}
|
||||
|
||||
export interface CloudSessionDataLoadedMessage {
|
||||
type: "cloudSessionDataLoaded"
|
||||
cloudSessionId: string
|
||||
title: string
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
export interface CloudSessionImportedMessage {
|
||||
type: "cloudSessionImported"
|
||||
cloudSessionId: string
|
||||
session: SessionInfo
|
||||
}
|
||||
|
||||
export interface CloudSessionImportFailedMessage {
|
||||
type: "cloudSessionImportFailed"
|
||||
cloudSessionId: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface OpenCloudSessionMessage {
|
||||
type: "openCloudSession"
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export interface ActionMessage {
|
||||
type: "action"
|
||||
action: string
|
||||
}
|
||||
|
||||
export interface SetChatBoxMessage {
|
||||
type: "setChatBoxMessage"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface AppendChatBoxMessage {
|
||||
type: "appendChatBoxMessage"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface AppendReviewCommentsMessage {
|
||||
type: "appendReviewComments"
|
||||
comments: ReviewComment[]
|
||||
autoSend?: boolean
|
||||
}
|
||||
|
||||
export interface TriggerTaskMessage {
|
||||
type: "triggerTask"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ProfileDataMessage {
|
||||
type: "profileData"
|
||||
data: ProfileData | null
|
||||
}
|
||||
|
||||
export interface DeviceAuthStartedMessage {
|
||||
type: "deviceAuthStarted"
|
||||
code?: string
|
||||
verificationUrl: string
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
export interface DeviceAuthCompleteMessage {
|
||||
type: "deviceAuthComplete"
|
||||
}
|
||||
|
||||
export interface DeviceAuthFailedMessage {
|
||||
type: "deviceAuthFailed"
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface DeviceAuthCancelledMessage {
|
||||
type: "deviceAuthCancelled"
|
||||
}
|
||||
|
||||
export interface NavigateMessage {
|
||||
type: "navigate"
|
||||
view: "newTask" | "marketplace" | "history" | "profile" | "settings" | "subAgentViewer"
|
||||
tab?: string
|
||||
}
|
||||
|
||||
export interface ProvidersLoadedMessage {
|
||||
type: "providersLoaded"
|
||||
providers: Record<string, Provider>
|
||||
connected: string[]
|
||||
defaults: Record<string, string>
|
||||
defaultSelection: ModelSelection
|
||||
authMethods: Record<string, ProviderAuthMethod[]>
|
||||
authStates: Record<string, ProviderAuthState>
|
||||
}
|
||||
|
||||
export interface AgentsLoadedMessage {
|
||||
type: "agentsLoaded"
|
||||
agents: AgentInfo[]
|
||||
allAgents: AgentInfo[]
|
||||
defaultAgent: string
|
||||
}
|
||||
|
||||
export interface SkillsLoadedMessage {
|
||||
type: "skillsLoaded"
|
||||
skills: SkillInfo[]
|
||||
}
|
||||
|
||||
export interface CommandsLoadedMessage {
|
||||
type: "commandsLoaded"
|
||||
commands: SlashCommandInfo[]
|
||||
}
|
||||
|
||||
export interface AutocompleteSettingsLoadedMessage {
|
||||
type: "autocompleteSettingsLoaded"
|
||||
settings: {
|
||||
enableAutoTrigger: boolean
|
||||
enableSmartInlineTaskKeybinding: boolean
|
||||
enableChatAutocomplete: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChatCompletionResultMessage {
|
||||
type: "chatCompletionResult"
|
||||
text: string
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface FileSearchItem {
|
||||
path: string
|
||||
type: "file" | "folder"
|
||||
}
|
||||
|
||||
export interface FileSearchResultMessage {
|
||||
type: "fileSearchResult"
|
||||
paths: string[]
|
||||
items?: FileSearchItem[]
|
||||
dir: string
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface TerminalContextResultMessage {
|
||||
type: "terminalContextResult"
|
||||
requestId: string
|
||||
content: string
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export interface TerminalContextErrorMessage {
|
||||
type: "terminalContextError"
|
||||
requestId: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface QuestionRequestMessage {
|
||||
type: "questionRequest"
|
||||
question: QuestionRequest
|
||||
}
|
||||
|
||||
export interface QuestionResolvedMessage {
|
||||
type: "questionResolved"
|
||||
requestID: string
|
||||
}
|
||||
|
||||
export interface QuestionErrorMessage {
|
||||
type: "questionError"
|
||||
requestID: string
|
||||
}
|
||||
|
||||
export interface SuggestionRequestMessage {
|
||||
type: "suggestionRequest"
|
||||
suggestion: SuggestionRequest
|
||||
}
|
||||
|
||||
export interface SuggestionResolvedMessage {
|
||||
type: "suggestionResolved"
|
||||
requestID: string
|
||||
}
|
||||
|
||||
export interface SuggestionErrorMessage {
|
||||
type: "suggestionError"
|
||||
requestID: string
|
||||
}
|
||||
|
||||
export interface BrowserSettingsLoadedMessage {
|
||||
type: "browserSettingsLoaded"
|
||||
settings: BrowserSettings
|
||||
}
|
||||
|
||||
export interface ClaudeCompatSettingLoadedMessage {
|
||||
type: "claudeCompatSettingLoaded"
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface ConfigLoadedMessage {
|
||||
type: "configLoaded"
|
||||
config: Config
|
||||
}
|
||||
|
||||
export interface ConfigUpdatedMessage {
|
||||
type: "configUpdated"
|
||||
config: Config
|
||||
}
|
||||
|
||||
export interface ConfigUpdateFailedMessage {
|
||||
type: "configUpdateFailed"
|
||||
message: string
|
||||
details?: string
|
||||
}
|
||||
|
||||
export interface GlobalConfigLoadedMessage {
|
||||
type: "globalConfigLoaded"
|
||||
config: Config
|
||||
}
|
||||
|
||||
export interface NotificationSettingsLoadedMessage {
|
||||
type: "notificationSettingsLoaded"
|
||||
settings: {
|
||||
notifyAgent: boolean
|
||||
notifyPermissions: boolean
|
||||
notifyErrors: boolean
|
||||
soundAgent: string
|
||||
soundPermissions: string
|
||||
soundErrors: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface TimelineSettingLoadedMessage {
|
||||
type: "timelineSettingLoaded"
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export interface NotificationsLoadedMessage {
|
||||
type: "notificationsLoaded"
|
||||
notifications: KilocodeNotification[]
|
||||
dismissedIds: string[]
|
||||
}
|
||||
|
||||
// Agent Manager worktree session metadata
|
||||
export interface AgentManagerSessionMetaMessage {
|
||||
type: "agentManager.sessionMeta"
|
||||
sessionId: string
|
||||
mode: SessionMode
|
||||
branch?: string
|
||||
path?: string
|
||||
parentBranch?: string
|
||||
}
|
||||
|
||||
// Agent Manager repo info (current branch of the main workspace)
|
||||
export interface AgentManagerRepoInfoMessage {
|
||||
type: "agentManager.repoInfo"
|
||||
branch: string
|
||||
defaultBranch?: string
|
||||
}
|
||||
|
||||
// Agent Manager worktree setup progress
|
||||
export interface AgentManagerWorktreeSetupMessage {
|
||||
type: "agentManager.worktreeSetup"
|
||||
status: "creating" | "starting" | "ready" | "error"
|
||||
message: string
|
||||
sessionId?: string
|
||||
branch?: string
|
||||
worktreeId?: string
|
||||
errorCode?: WorktreeErrorCode
|
||||
}
|
||||
|
||||
// Agent Manager session added to an existing worktree (no setup overlay needed)
|
||||
export interface AgentManagerSessionAddedMessage {
|
||||
type: "agentManager.sessionAdded"
|
||||
sessionId: string
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
// Agent Manager session forked from an existing session
|
||||
export interface AgentManagerSessionForkedMessage {
|
||||
type: "agentManager.sessionForked"
|
||||
sessionId: string
|
||||
forkedFromId: string
|
||||
worktreeId?: string
|
||||
}
|
||||
|
||||
// Full state push from extension to webview
|
||||
export interface AgentManagerStateMessage {
|
||||
type: "agentManager.state"
|
||||
worktrees: WorktreeState[]
|
||||
sessions: ManagedSessionState[]
|
||||
sections?: SectionState[]
|
||||
staleWorktreeIds?: string[]
|
||||
tabOrder?: Record<string, string[]>
|
||||
worktreeOrder?: string[]
|
||||
sessionsCollapsed?: boolean
|
||||
reviewDiffStyle?: "unified" | "split"
|
||||
isGitRepo?: boolean
|
||||
defaultBaseBranch?: string
|
||||
runStatuses?: RunStatus[]
|
||||
runScriptConfigured?: boolean
|
||||
runScriptPath?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Manager terminal messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AgentManagerTerminalCreatedMessage {
|
||||
type: "agentManager.terminal.created"
|
||||
/** null for LOCAL, worktree id otherwise */
|
||||
worktreeId: string | null
|
||||
terminalId: string
|
||||
title: string
|
||||
wsUrl: string
|
||||
}
|
||||
|
||||
export interface AgentManagerTerminalClosedMessage {
|
||||
type: "agentManager.terminal.closed"
|
||||
terminalId: string
|
||||
}
|
||||
|
||||
export interface AgentManagerTerminalErrorMessage {
|
||||
type: "agentManager.terminal.error"
|
||||
terminalId?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface AgentManagerRunStatusMessage extends RunStatus {
|
||||
type: "agentManager.runStatus"
|
||||
}
|
||||
|
||||
// Resolved keybindings for agent manager actions
|
||||
export interface AgentManagerKeybindingsMessage {
|
||||
type: "agentManager.keybindings"
|
||||
bindings: Record<string, string>
|
||||
}
|
||||
|
||||
// Multi-version creation progress (extension → webview)
|
||||
export interface AgentManagerMultiVersionProgressMessage {
|
||||
type: "agentManager.multiVersionProgress"
|
||||
status: "creating" | "done"
|
||||
total: number
|
||||
completed: number
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
// Stored variant selections loaded from extension globalState (extension → webview)
|
||||
export interface VariantsLoadedMessage {
|
||||
type: "variantsLoaded"
|
||||
variants: Record<string, string>
|
||||
}
|
||||
|
||||
export interface RecentsLoadedMessage {
|
||||
type: "recentsLoaded"
|
||||
recents: ModelSelection[]
|
||||
}
|
||||
|
||||
export interface FavoritesLoadedMessage {
|
||||
type: "favoritesLoaded"
|
||||
favorites: ModelSelection[]
|
||||
}
|
||||
|
||||
// Per-mode model selections loaded from model.json (extension → webview)
|
||||
export interface ModelSelectionsLoadedMessage {
|
||||
type: "modelSelectionsLoaded"
|
||||
selections: Record<string, ModelSelection>
|
||||
}
|
||||
|
||||
export interface AgentManagerBranchesMessage {
|
||||
type: "agentManager.branches"
|
||||
branches: BranchInfo[]
|
||||
defaultBranch: string
|
||||
}
|
||||
|
||||
export interface AgentManagerExternalWorktreesMessage {
|
||||
type: "agentManager.externalWorktrees"
|
||||
worktrees: ExternalWorktreeInfo[]
|
||||
}
|
||||
|
||||
// Agent Manager Import tab: result feedback (extension → webview)
|
||||
export interface AgentManagerImportResultMessage {
|
||||
type: "agentManager.importResult"
|
||||
success: boolean
|
||||
message: string
|
||||
errorCode?: WorktreeErrorCode
|
||||
}
|
||||
|
||||
// Agent Manager: Diff data push (extension → webview)
|
||||
export interface AgentManagerWorktreeDiffMessage {
|
||||
type: "agentManager.worktreeDiff"
|
||||
sessionId: string
|
||||
diffs: WorktreeFileDiff[]
|
||||
}
|
||||
|
||||
export interface AgentManagerWorktreeDiffFileMessage {
|
||||
type: "agentManager.worktreeDiffFile"
|
||||
sessionId: string
|
||||
file: string
|
||||
diff: WorktreeFileDiff | null
|
||||
}
|
||||
|
||||
// Agent Manager: Diff loading state (extension → webview)
|
||||
export interface AgentManagerWorktreeDiffLoadingMessage {
|
||||
type: "agentManager.worktreeDiffLoading"
|
||||
sessionId: string
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export interface AgentManagerApplyWorktreeDiffResultMessage {
|
||||
type: "agentManager.applyWorktreeDiffResult"
|
||||
worktreeId: string
|
||||
status: AgentManagerApplyWorktreeDiffStatus
|
||||
message: string
|
||||
conflicts?: AgentManagerApplyWorktreeDiffConflict[]
|
||||
}
|
||||
|
||||
// Agent Manager: Revert single file result (extension → webview)
|
||||
export interface AgentManagerRevertWorktreeFileResultMessage {
|
||||
type: "agentManager.revertWorktreeFileResult"
|
||||
sessionId: string
|
||||
file: string
|
||||
status: "success" | "error"
|
||||
message: string
|
||||
}
|
||||
|
||||
// Agent Manager: Worktree git stats push (extension → webview)
|
||||
export interface AgentManagerWorktreeStatsMessage {
|
||||
type: "agentManager.worktreeStats"
|
||||
stats: WorktreeGitStats[]
|
||||
}
|
||||
|
||||
// Agent Manager: Local workspace git stats push (extension → webview)
|
||||
export interface AgentManagerLocalStatsMessage {
|
||||
type: "agentManager.localStats"
|
||||
stats: LocalGitStats
|
||||
}
|
||||
|
||||
// Agent Manager: PR status push (extension → webview)
|
||||
export interface AgentManagerPRStatusMessage {
|
||||
type: "agentManager.prStatus"
|
||||
worktreeId: string
|
||||
pr: PRStatus | null
|
||||
error?: "gh_missing" | "gh_auth" | "fetch_failed"
|
||||
}
|
||||
|
||||
// Sidebar: Live worktree diff stats (extension → webview)
|
||||
export interface WorktreeStatsLoadedMessage {
|
||||
type: "worktreeStatsLoaded"
|
||||
files: number
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
// Set the model for a session (extension → webview, used during multi-version creation)
|
||||
export interface AgentManagerSetSessionModelMessage {
|
||||
type: "agentManager.setSessionModel"
|
||||
sessionId: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
// Request webview to send initial prompt to a newly created session (extension → webview)
|
||||
export interface AgentManagerSendInitialMessage {
|
||||
type: "agentManager.sendInitialMessage"
|
||||
sessionId: string
|
||||
worktreeId: string
|
||||
text?: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
agent?: string
|
||||
files?: Array<{ mime: string; url: string }>
|
||||
}
|
||||
|
||||
// Enhance prompt result (extension → webview)
|
||||
export interface EnhancePromptResultMessage {
|
||||
type: "enhancePromptResult"
|
||||
text: string
|
||||
requestId: string
|
||||
}
|
||||
|
||||
// Enhance prompt error (extension → webview)
|
||||
export interface EnhancePromptErrorMessage {
|
||||
type: "enhancePromptError"
|
||||
error: string
|
||||
requestId: string
|
||||
}
|
||||
|
||||
// Sub-agent viewer: open a child session in read-only mode (extension → webview)
|
||||
export interface ViewSubAgentSessionMessage {
|
||||
type: "viewSubAgentSession"
|
||||
sessionID: string
|
||||
}
|
||||
|
||||
export interface DiffViewerDiffsMessage {
|
||||
type: "diffViewer.diffs"
|
||||
diffs: WorktreeFileDiff[]
|
||||
}
|
||||
|
||||
export interface DiffViewerLoadingMessage {
|
||||
type: "diffViewer.loading"
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export interface DiffViewerRevertFileResultMessage {
|
||||
type: "diffViewer.revertFileResult"
|
||||
file: string
|
||||
status: "success" | "error"
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ClearPendingPromptsMessage {
|
||||
type: "clearPendingPrompts"
|
||||
}
|
||||
|
||||
export interface ExtensionDataReadyMessage {
|
||||
type: "extensionDataReady"
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Marketplace Messages
|
||||
// ============================================
|
||||
|
||||
export interface MarketplaceDataMessage {
|
||||
type: "marketplaceData"
|
||||
marketplaceItems: MarketplaceItem[]
|
||||
marketplaceInstalledMetadata: MarketplaceInstalledMetadata
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
export interface MarketplaceInstallResultMessage {
|
||||
type: "marketplaceInstallResult"
|
||||
success: boolean
|
||||
slug: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface MarketplaceRemoveResultMessage {
|
||||
type: "marketplaceRemoveResult"
|
||||
success: boolean
|
||||
slug: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ProviderOAuthReadyMessage {
|
||||
type: "providerOAuthReady"
|
||||
requestId: string
|
||||
providerID: string
|
||||
authorization: ProviderAuthAuthorization
|
||||
}
|
||||
|
||||
export interface ProviderConnectedMessage {
|
||||
type: "providerConnected"
|
||||
requestId: string
|
||||
providerID: string
|
||||
}
|
||||
|
||||
export interface ProviderDisconnectedMessage {
|
||||
type: "providerDisconnected"
|
||||
requestId: string
|
||||
providerID: string
|
||||
}
|
||||
|
||||
export interface ProviderActionErrorMessage {
|
||||
type: "providerActionError"
|
||||
requestId: string
|
||||
providerID: string
|
||||
action: "authorize" | "connect" | "disconnect"
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CustomProviderModelsFetchedMessage {
|
||||
type: "customProviderModelsFetched"
|
||||
requestId: string
|
||||
models?: Array<{ id: string; name: string }>
|
||||
error?: string
|
||||
/** True when error was HTTP 401/403 — hints the user to check their API key */
|
||||
auth?: boolean
|
||||
}
|
||||
|
||||
export interface McpStatusEntry {
|
||||
status: "connected" | "disabled" | "failed" | "needs_auth" | "needs_client_registration"
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface McpStatusLoadedMessage {
|
||||
type: "mcpStatusLoaded"
|
||||
status: Record<string, McpStatusEntry>
|
||||
}
|
||||
|
||||
// Continue in Worktree: progress updates (extension → webview)
|
||||
export interface ContinueInWorktreeProgressMessage {
|
||||
type: "continueInWorktreeProgress"
|
||||
status: ContinueInWorktreeStatus
|
||||
detail?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface RemoteStatusMessage {
|
||||
type: "remoteStatus"
|
||||
enabled: boolean
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export type ExtensionMessage =
|
||||
| ReadyMessage
|
||||
| GitStatusMessage
|
||||
| ConnectionStateMessage
|
||||
| ErrorMessage
|
||||
| SendMessageFailedMessage
|
||||
| PartUpdatedMessage
|
||||
| PartsUpdatedMessage
|
||||
| SessionStatusMessage
|
||||
| SessionErrorMessage
|
||||
| PermissionRequestMessage
|
||||
| PermissionResolvedMessage
|
||||
| PermissionErrorMessage
|
||||
| TodoUpdatedMessage
|
||||
| SessionCreatedMessage
|
||||
| SessionForkedMessage
|
||||
| SessionUpdatedMessage
|
||||
| SessionDeletedMessage
|
||||
| MessageRemovedMessage
|
||||
| MessagesLoadedMessage
|
||||
| MessageCreatedMessage
|
||||
| SessionsLoadedMessage
|
||||
| CloudSessionsLoadedMessage
|
||||
| GitRemoteUrlLoadedMessage
|
||||
| ActionMessage
|
||||
| ProfileDataMessage
|
||||
| DeviceAuthStartedMessage
|
||||
| DeviceAuthCompleteMessage
|
||||
| DeviceAuthFailedMessage
|
||||
| DeviceAuthCancelledMessage
|
||||
| NavigateMessage
|
||||
| ProvidersLoadedMessage
|
||||
| AgentsLoadedMessage
|
||||
| SkillsLoadedMessage
|
||||
| CommandsLoadedMessage
|
||||
| AutocompleteSettingsLoadedMessage
|
||||
| ChatCompletionResultMessage
|
||||
| FileSearchResultMessage
|
||||
| TerminalContextResultMessage
|
||||
| TerminalContextErrorMessage
|
||||
| QuestionRequestMessage
|
||||
| QuestionResolvedMessage
|
||||
| QuestionErrorMessage
|
||||
| SuggestionRequestMessage
|
||||
| SuggestionResolvedMessage
|
||||
| SuggestionErrorMessage
|
||||
| BrowserSettingsLoadedMessage
|
||||
| ClaudeCompatSettingLoadedMessage
|
||||
| ConfigLoadedMessage
|
||||
| ConfigUpdatedMessage
|
||||
| ConfigUpdateFailedMessage
|
||||
| GlobalConfigLoadedMessage
|
||||
| NotificationSettingsLoadedMessage
|
||||
| TimelineSettingLoadedMessage
|
||||
| NotificationsLoadedMessage
|
||||
| AgentManagerSessionMetaMessage
|
||||
| AgentManagerRepoInfoMessage
|
||||
| AgentManagerWorktreeSetupMessage
|
||||
| AgentManagerSessionAddedMessage
|
||||
| AgentManagerSessionForkedMessage
|
||||
| AgentManagerStateMessage
|
||||
| AgentManagerRunStatusMessage
|
||||
| AgentManagerKeybindingsMessage
|
||||
| AgentManagerMultiVersionProgressMessage
|
||||
| AgentManagerSetSessionModelMessage
|
||||
| AgentManagerSendInitialMessage
|
||||
| SetChatBoxMessage
|
||||
| AppendChatBoxMessage
|
||||
| AppendReviewCommentsMessage
|
||||
| TriggerTaskMessage
|
||||
| VariantsLoadedMessage
|
||||
| CloudSessionDataLoadedMessage
|
||||
| CloudSessionImportedMessage
|
||||
| CloudSessionImportFailedMessage
|
||||
| OpenCloudSessionMessage
|
||||
| AgentManagerBranchesMessage
|
||||
| AgentManagerExternalWorktreesMessage
|
||||
| AgentManagerImportResultMessage
|
||||
| WorkspaceDirectoryChangedMessage
|
||||
| AgentManagerWorktreeDiffMessage
|
||||
| AgentManagerWorktreeDiffFileMessage
|
||||
| AgentManagerWorktreeDiffLoadingMessage
|
||||
| AgentManagerApplyWorktreeDiffResultMessage
|
||||
| AgentManagerRevertWorktreeFileResultMessage
|
||||
| AgentManagerWorktreeStatsMessage
|
||||
| AgentManagerLocalStatsMessage
|
||||
| AgentManagerPRStatusMessage
|
||||
| AgentManagerTerminalCreatedMessage
|
||||
| AgentManagerTerminalClosedMessage
|
||||
| AgentManagerTerminalErrorMessage
|
||||
// legacy-migration start
|
||||
| MigrationStateMessage
|
||||
| LegacyMigrationDataMessage
|
||||
| LegacyMigrationProgressMessage
|
||||
| LegacyMigrationSessionProgressMessage
|
||||
| LegacyMigrationCompleteMessage
|
||||
// legacy-migration end
|
||||
| EnhancePromptResultMessage
|
||||
| EnhancePromptErrorMessage
|
||||
| ViewSubAgentSessionMessage
|
||||
| DiffViewerDiffsMessage
|
||||
| DiffViewerLoadingMessage
|
||||
| DiffViewerRevertFileResultMessage
|
||||
| MarketplaceDataMessage
|
||||
| MarketplaceInstallResultMessage
|
||||
| MarketplaceRemoveResultMessage
|
||||
| ProviderOAuthReadyMessage
|
||||
| ProviderConnectedMessage
|
||||
| ProviderDisconnectedMessage
|
||||
| ProviderActionErrorMessage
|
||||
| CustomProviderModelsFetchedMessage
|
||||
| RecentsLoadedMessage
|
||||
| FavoritesLoadedMessage
|
||||
| ModelSelectionsLoadedMessage
|
||||
| LanguageChangedMessage
|
||||
| ContinueInWorktreeProgressMessage
|
||||
| WorktreeStatsLoadedMessage
|
||||
| McpStatusLoadedMessage
|
||||
| ClearPendingPromptsMessage
|
||||
| ExtensionDataReadyMessage
|
||||
| RemoteStatusMessage
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Types for extension <-> webview message communication
|
||||
*/
|
||||
|
||||
export * from "./connection"
|
||||
export * from "./parts"
|
||||
export * from "./sessions"
|
||||
export * from "./permissions"
|
||||
export * from "./questions"
|
||||
export * from "./providers"
|
||||
export * from "./agents"
|
||||
export * from "./config"
|
||||
export * from "./profile"
|
||||
export * from "./agent-manager"
|
||||
export * from "./migration"
|
||||
export * from "./extension-messages"
|
||||
export * from "./webview-messages"
|
||||
@@ -0,0 +1,150 @@
|
||||
// legacy-migration start
|
||||
export interface MigrationProviderInfo {
|
||||
profileName: string
|
||||
provider: string
|
||||
model?: string
|
||||
hasApiKey: boolean
|
||||
supported: boolean
|
||||
newProviderName?: string
|
||||
}
|
||||
|
||||
export interface MigrationMcpServerInfo {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface MigrationCustomModeInfo {
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface LegacyAutocompleteSettings {
|
||||
enableAutoTrigger?: boolean
|
||||
enableSmartInlineTaskKeybinding?: boolean
|
||||
enableChatAutocomplete?: boolean
|
||||
}
|
||||
|
||||
export interface LegacySettings {
|
||||
autoApprovalEnabled?: boolean
|
||||
allowedCommands?: string[]
|
||||
deniedCommands?: string[]
|
||||
// Fine-grained auto-approval (legacy globalState keys — no prefix)
|
||||
alwaysAllowReadOnly?: boolean
|
||||
alwaysAllowReadOnlyOutsideWorkspace?: boolean
|
||||
alwaysAllowWrite?: boolean
|
||||
alwaysAllowExecute?: boolean
|
||||
alwaysAllowMcp?: boolean
|
||||
alwaysAllowModeSwitch?: boolean
|
||||
alwaysAllowSubtasks?: boolean
|
||||
language?: string
|
||||
autocomplete?: LegacyAutocompleteSettings
|
||||
}
|
||||
|
||||
export interface MigrationSessionInfo {
|
||||
id: string
|
||||
title: string
|
||||
directory: string
|
||||
time: number
|
||||
}
|
||||
|
||||
export interface MigrationResultItem {
|
||||
item: string
|
||||
category: "provider" | "mcpServer" | "customMode" | "session" | "defaultModel" | "settings"
|
||||
status: "success" | "warning" | "error"
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface MigrationStateMessage {
|
||||
type: "migrationState"
|
||||
needed: boolean
|
||||
data?: {
|
||||
providers: MigrationProviderInfo[]
|
||||
mcpServers: MigrationMcpServerInfo[]
|
||||
customModes: MigrationCustomModeInfo[]
|
||||
sessions?: MigrationSessionInfo[]
|
||||
defaultModel?: { provider: string; model: string }
|
||||
settings?: LegacySettings
|
||||
}
|
||||
}
|
||||
|
||||
export interface LegacyMigrationDataMessage {
|
||||
type: "legacyMigrationData"
|
||||
data: {
|
||||
providers: MigrationProviderInfo[]
|
||||
mcpServers: MigrationMcpServerInfo[]
|
||||
customModes: MigrationCustomModeInfo[]
|
||||
sessions?: MigrationSessionInfo[]
|
||||
defaultModel?: { provider: string; model: string }
|
||||
settings?: LegacySettings
|
||||
}
|
||||
}
|
||||
|
||||
export interface LegacyMigrationProgressMessage {
|
||||
type: "legacyMigrationProgress"
|
||||
item: string
|
||||
status: "migrating" | "success" | "warning" | "error"
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type LegacyMigrationSessionPhase = "preparing" | "storing" | "skipped" | "done" | "summary" | "error"
|
||||
|
||||
export interface LegacyMigrationSessionProgressMessage {
|
||||
type: "legacyMigrationSessionProgress"
|
||||
session: MigrationSessionInfo
|
||||
index: number
|
||||
total: number
|
||||
phase: LegacyMigrationSessionPhase
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface LegacyMigrationCompleteMessage {
|
||||
type: "legacyMigrationComplete"
|
||||
results: MigrationResultItem[]
|
||||
}
|
||||
|
||||
export interface RequestLegacyMigrationDataMessage {
|
||||
type: "requestLegacyMigrationData"
|
||||
}
|
||||
|
||||
export interface MigrationAutoApprovalSelections {
|
||||
commandRules: boolean
|
||||
readPermission: boolean
|
||||
writePermission: boolean
|
||||
executePermission: boolean
|
||||
mcpPermission: boolean
|
||||
taskPermission: boolean
|
||||
}
|
||||
|
||||
export interface MigrationSessionSelection {
|
||||
id: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export interface StartLegacyMigrationMessage {
|
||||
type: "startLegacyMigration"
|
||||
selections: {
|
||||
providers: string[]
|
||||
mcpServers: string[]
|
||||
customModes: string[]
|
||||
sessions?: MigrationSessionSelection[]
|
||||
defaultModel: boolean
|
||||
settings: {
|
||||
autoApproval: MigrationAutoApprovalSelections
|
||||
language: boolean
|
||||
autocomplete: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface SkipLegacyMigrationMessage {
|
||||
type: "skipLegacyMigration"
|
||||
}
|
||||
|
||||
export interface ClearLegacyDataMessage {
|
||||
type: "clearLegacyData"
|
||||
}
|
||||
|
||||
export interface FinalizeLegacyMigrationMessage {
|
||||
type: "finalizeLegacyMigration"
|
||||
}
|
||||
// legacy-migration end
|
||||
@@ -0,0 +1,94 @@
|
||||
// Tool state for tool parts
|
||||
export type ToolState =
|
||||
| { status: "pending"; input: Record<string, unknown> }
|
||||
| { status: "running"; input: Record<string, unknown>; title?: string }
|
||||
| { status: "completed"; input: Record<string, unknown>; output: string; title: string }
|
||||
| { status: "error"; input: Record<string, unknown>; error: string }
|
||||
|
||||
// Base part interface - all parts have these fields
|
||||
export interface BasePart {
|
||||
id: string
|
||||
sessionID?: string
|
||||
messageID?: string
|
||||
}
|
||||
|
||||
// Part types from the backend
|
||||
export interface TextPart extends BasePart {
|
||||
type: "text"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface FilePartSource {
|
||||
type: "file"
|
||||
path: string
|
||||
text: {
|
||||
value: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface FilePart extends BasePart {
|
||||
type: "file"
|
||||
mime: string
|
||||
url: string
|
||||
filename?: string
|
||||
source?: FilePartSource
|
||||
}
|
||||
|
||||
export interface ToolPart extends BasePart {
|
||||
type: "tool"
|
||||
tool: string
|
||||
state: ToolState
|
||||
}
|
||||
|
||||
export interface ReasoningPart extends BasePart {
|
||||
type: "reasoning"
|
||||
text: string
|
||||
}
|
||||
|
||||
// Step parts from the backend
|
||||
export interface StepStartPart extends BasePart {
|
||||
type: "step-start"
|
||||
}
|
||||
|
||||
export interface StepFinishPart extends BasePart {
|
||||
type: "step-finish"
|
||||
reason?: string
|
||||
cost?: number
|
||||
tokens?: {
|
||||
input: number
|
||||
output: number
|
||||
reasoning?: number
|
||||
cache?: { read: number; write: number }
|
||||
}
|
||||
}
|
||||
|
||||
export type Part = TextPart | FilePart | ToolPart | ReasoningPart | StepStartPart | StepFinishPart
|
||||
|
||||
// Part delta for streaming updates
|
||||
export interface PartDelta {
|
||||
type: "text-delta"
|
||||
textDelta?: string
|
||||
}
|
||||
|
||||
// Token usage for assistant messages
|
||||
export interface TokenUsage {
|
||||
input: number
|
||||
output: number
|
||||
reasoning?: number
|
||||
cache?: { read: number; write: number }
|
||||
}
|
||||
|
||||
// Context usage derived from the last assistant message's tokens
|
||||
export interface ContextUsage {
|
||||
tokens: number
|
||||
percentage: number | null
|
||||
}
|
||||
|
||||
export interface FileAttachment {
|
||||
mime: string
|
||||
url: string
|
||||
filename?: string
|
||||
source?: FilePartSource
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export type PermissionLevel = "allow" | "ask" | "deny"
|
||||
|
||||
/** null in a PermissionRule object is a delete sentinel — removes the key from the config */
|
||||
export type PermissionRule = PermissionLevel | Record<string, PermissionLevel | null>
|
||||
|
||||
export type PermissionConfig = Partial<Record<string, PermissionRule>>
|
||||
|
||||
// A single resolved permission rule from the CLI backend (matches PermissionNext.Rule)
|
||||
export interface PermissionRuleItem {
|
||||
permission: string
|
||||
pattern: string
|
||||
action: PermissionLevel
|
||||
}
|
||||
|
||||
// Permission request
|
||||
export interface PermissionFileDiff {
|
||||
file: string
|
||||
patch?: string
|
||||
before?: string
|
||||
after?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
export interface PermissionRequest {
|
||||
id: string
|
||||
sessionID: string
|
||||
toolName: string
|
||||
patterns: string[]
|
||||
always: string[]
|
||||
args: Record<string, unknown> & {
|
||||
rules?: string[]
|
||||
diff?: string
|
||||
filepath?: string
|
||||
filediff?: PermissionFileDiff
|
||||
}
|
||||
message?: string
|
||||
tool?: { messageID: string; callID: string }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Kilo notification types (mirrored from kilo-gateway)
|
||||
export interface KilocodeNotificationAction {
|
||||
actionText: string
|
||||
actionURL: string
|
||||
}
|
||||
|
||||
export interface KilocodeNotification {
|
||||
id: string
|
||||
title: string
|
||||
message: string
|
||||
action?: KilocodeNotificationAction
|
||||
showIn?: string[]
|
||||
suggestModelId?: string
|
||||
}
|
||||
|
||||
// Profile types from kilo-gateway
|
||||
export interface KilocodeBalance {
|
||||
balance: number
|
||||
}
|
||||
|
||||
export interface ProfileData {
|
||||
profile: {
|
||||
email: string
|
||||
name?: string
|
||||
organizations?: Array<{ id: string; name: string; role: string }>
|
||||
}
|
||||
balance: KilocodeBalance | null
|
||||
currentOrgId: string | null
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Provider/model types for model selector
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string
|
||||
name: string
|
||||
inputPrice?: number
|
||||
outputPrice?: number
|
||||
contextLength?: number
|
||||
releaseDate?: string
|
||||
latest?: boolean
|
||||
// Actual shape returned by the server (Provider.Model)
|
||||
limit?: { context: number; input?: number; output: number }
|
||||
variants?: Record<string, Record<string, unknown>>
|
||||
capabilities?: {
|
||||
reasoning: boolean
|
||||
input?: { text: boolean; image: boolean; audio: boolean; video: boolean; pdf: boolean }
|
||||
}
|
||||
options?: { description?: string }
|
||||
recommendedIndex?: number
|
||||
isFree?: boolean
|
||||
cost?: {
|
||||
input: number
|
||||
output: number
|
||||
cache?: {
|
||||
read: number
|
||||
write: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
models: Record<string, ProviderModel>
|
||||
source?: "env" | "config" | "custom" | "api"
|
||||
env?: string[]
|
||||
}
|
||||
|
||||
export interface ModelSelection {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
export type ProviderAuthState = "api" | "oauth" | "wellknown"
|
||||
|
||||
export interface ProviderConfig {
|
||||
name?: string
|
||||
api_key?: string
|
||||
base_url?: string
|
||||
models?: Record<string, unknown>
|
||||
npm?: string
|
||||
env?: string[]
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Todo item
|
||||
export interface TodoItem {
|
||||
id: string
|
||||
content: string
|
||||
status: "pending" | "in_progress" | "completed"
|
||||
}
|
||||
|
||||
// Question types
|
||||
export interface QuestionOption {
|
||||
label: string
|
||||
description: string
|
||||
mode?: string
|
||||
// Optional i18n keys — the backend fills these for strings it wants translated in the webview.
|
||||
// The canonical English `label` stays on the reply wire, so server-side matching is unaffected.
|
||||
labelKey?: string
|
||||
descriptionKey?: string
|
||||
}
|
||||
|
||||
export interface QuestionInfo {
|
||||
question: string
|
||||
header: string
|
||||
options: QuestionOption[]
|
||||
multiple?: boolean
|
||||
custom?: boolean
|
||||
// Optional i18n keys for question text and header (see QuestionOption for details).
|
||||
questionKey?: string
|
||||
headerKey?: string
|
||||
}
|
||||
|
||||
export interface QuestionRequest {
|
||||
id: string
|
||||
sessionID: string
|
||||
questions: QuestionInfo[]
|
||||
blocking?: boolean
|
||||
tool?: {
|
||||
messageID: string
|
||||
callID: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface SuggestionAction {
|
||||
label: string
|
||||
description?: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
export interface SuggestionRequest {
|
||||
id: string
|
||||
sessionID: string
|
||||
text: string
|
||||
actions: SuggestionAction[]
|
||||
blocking?: boolean
|
||||
tool?: {
|
||||
messageID: string
|
||||
callID: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Part, TokenUsage } from "./parts"
|
||||
|
||||
// Message structure (simplified for webview)
|
||||
export interface Message {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "user" | "assistant"
|
||||
content?: string
|
||||
parts?: Part[]
|
||||
createdAt: string
|
||||
time?: { created: number; completed?: number }
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
mode?: string
|
||||
parentID?: string
|
||||
path?: { cwd: string; root: string }
|
||||
error?: { name: string; data?: Record<string, unknown> }
|
||||
summary?: { title?: string; body?: string; diffs?: unknown[] } | boolean
|
||||
cost?: number
|
||||
tokens?: TokenUsage
|
||||
finish?: string
|
||||
}
|
||||
|
||||
// File diff info (matches Snapshot.FileDiff from CLI backend)
|
||||
export interface SessionFileDiff {
|
||||
file: string
|
||||
before: string
|
||||
after: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
// Session info (simplified for webview)
|
||||
export interface SessionInfo {
|
||||
id: string
|
||||
parentID?: string | null
|
||||
title?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
revert?: {
|
||||
messageID: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
diff?: string
|
||||
} | null
|
||||
summary?: {
|
||||
additions: number
|
||||
deletions: number
|
||||
files: number
|
||||
diffs?: SessionFileDiff[]
|
||||
} | null
|
||||
}
|
||||
|
||||
// Cloud session info (from Kilo cloud API)
|
||||
export interface CloudSessionInfo {
|
||||
session_id: string
|
||||
title: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,33 @@
|
||||
# @kilocode/cli
|
||||
|
||||
## 7.2.22
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9455](https://github.com/Kilo-Org/kilocode/pull/9455) [`567ca0d`](https://github.com/Kilo-Org/kilocode/commit/567ca0d34178a6a896aa58c10cc946565c116d4e) - Fix a 1-2 second startup delay before home content (agents, news, tips) appears in the TUI.
|
||||
|
||||
- [#9425](https://github.com/Kilo-Org/kilocode/pull/9425) [`6ee160f`](https://github.com/Kilo-Org/kilocode/commit/6ee160f89c10293d635990798779988d34b092b4) - Preserve typed text in the main prompt when a blocking question, suggestion, permission, or network overlay is shown and then dismissed.
|
||||
|
||||
## 7.2.21
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#8587](https://github.com/Kilo-Org/kilocode/pull/8587) [`010a946`](https://github.com/Kilo-Org/kilocode/commit/010a94698e449bdd9270f44e53aa209dd4c7a248) - The agent now detects and preserves the original text encoding of files when reading and editing them, so non-UTF-8 files are displayed correctly to the model and written back in their original encoding. New files are still created as UTF-8 without BOM — detection only applies when overwriting or editing an existing file.
|
||||
|
||||
Supported: UTF-8 (with or without BOM), UTF-16 with BOM, and common legacy Latin and CJK encodings (Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR, Windows-1251, KOI8-R, ISO-8859, and others).
|
||||
|
||||
Not supported: UTF-16 without BOM, UTF-32.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9298](https://github.com/Kilo-Org/kilocode/pull/9298) [`8d06a08`](https://github.com/Kilo-Org/kilocode/commit/8d06a083bce0d87ad55adeb57b043cc5607979eb) - CLI suggestions now render inline in the conversation at the position of the suggest tool call, instead of as a separate bar above the prompt input. The inline bar renders as a single full-width row with a subtle background and clickable action buttons, matching the VS Code extension. Dismissal happens automatically when you send a new prompt. Blocking suggestions still use the above-prompt overlay.
|
||||
|
||||
- [#9298](https://github.com/Kilo-Org/kilocode/pull/9298) [`2ba203b`](https://github.com/Kilo-Org/kilocode/commit/2ba203b6bdad1b759b26501e74d278d13f77f69b) - CLI suggestions now render above an active input prompt. You can keep typing and submit a new message while a suggestion is on screen — sending a message auto-dismisses the pending suggestion, matching the VS Code extension behavior. The redundant "Dismiss" row has been removed; click an option to accept, or press Esc to dismiss.
|
||||
|
||||
- [#9344](https://github.com/Kilo-Org/kilocode/pull/9344) [`c032fc2`](https://github.com/Kilo-Org/kilocode/commit/c032fc2021c55589ff7aee747d8f8a871e77bc56) - Fix an infinite "busy" loop that could occur when a model kept reporting context overflow after every compaction. Each turn now caps compactions at three attempts and closes the turn with a visible context-overflow error instead of silently looping forever.
|
||||
|
||||
- [#9408](https://github.com/Kilo-Org/kilocode/pull/9408) [`c214d63`](https://github.com/Kilo-Org/kilocode/commit/c214d63afb426df0b3499b5240fe5ce525561497) - Narrow when the CLI suggests a local code review so it no longer surfaces after PR-comment replies, reactive fixes (CI/lint failures, reported issues), trivial edits, non-implementation work (research, commits, docs), or review-adjacent turns.
|
||||
|
||||
## 7.2.19
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"name": "@kilocode/cli",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -154,8 +154,10 @@
|
||||
"gray-matter": "4.0.3",
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "catalog:",
|
||||
"iconv-lite": "0.7.2",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jschardet": "3.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
|
||||
@@ -52,6 +52,9 @@ export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
onKeyDown: (e: KeyEvent) => void
|
||||
onCursorChange: () => void
|
||||
// kilocode_change start - let the prompt close autocomplete without mutating draft text
|
||||
dismiss: () => void
|
||||
// kilocode_change end
|
||||
visible: false | "@" | "/"
|
||||
}
|
||||
|
||||
@@ -487,6 +490,14 @@ export function Autocomplete(props: {
|
||||
})
|
||||
}
|
||||
|
||||
// kilocode_change start - keep slash text intact when overlays hide the prompt,
|
||||
// but still allow normal autocomplete dismissal to clean it up.
|
||||
function dismiss() {
|
||||
if (!store.visible) return
|
||||
command.keybinds(true)
|
||||
setStore("visible", false)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
const text = props.input().plainText
|
||||
if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
|
||||
@@ -497,15 +508,20 @@ export function Autocomplete(props: {
|
||||
draft.input = props.input().plainText
|
||||
})
|
||||
}
|
||||
command.keybinds(true)
|
||||
setStore("visible", false)
|
||||
dismiss()
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
onMount(() => {
|
||||
props.ref({
|
||||
get visible() {
|
||||
return store.visible
|
||||
},
|
||||
// kilocode_change start
|
||||
dismiss() {
|
||||
dismiss()
|
||||
},
|
||||
// kilocode_change end
|
||||
onInput(value) {
|
||||
if (store.visible) {
|
||||
if (
|
||||
|
||||
@@ -468,6 +468,14 @@ export function Prompt(props: PromptProps) {
|
||||
props.ref?.(undefined)
|
||||
})
|
||||
|
||||
// kilocode_change start - close autocomplete while blocking overlays hide the prompt
|
||||
createEffect(() => {
|
||||
if (props.visible === false || props.disabled) {
|
||||
auto()?.dismiss()
|
||||
}
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.visible === false || dialog.stack.length > 0) {
|
||||
|
||||
@@ -630,6 +630,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return store.status
|
||||
},
|
||||
get ready() {
|
||||
// return true // kilocode_change - upstream #23037 left this debug path enabled; keep it commented so future merges do not restore eager ready state.
|
||||
if (process.env.KILO_FAST_BOOT) return true
|
||||
return store.status !== "loading"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@kilocode/plugin/tui"
|
||||
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
|
||||
import { children, mergeProps } from "solid-js" // kilocode_change
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
|
||||
@@ -21,7 +22,20 @@ function empty<Name extends string>(_props: TuiSlotProps<Name>) {
|
||||
|
||||
let view: Slot = empty
|
||||
|
||||
export const Slot: Slot = (props) => view(props)
|
||||
// kilocode_change start - stabilize fallback children so replace-mode slots
|
||||
// don't recreate stateful defaults like the session prompt on prop changes.
|
||||
// mergeProps (instead of spread) preserves SolidJS prop reactivity so things
|
||||
// like ref, visible, disabled, on_submit keep flowing through to the slot.
|
||||
export const Slot = <Name extends string>(props: TuiSlotProps<Name>) => {
|
||||
const value = children(() => props.children)
|
||||
const merged = mergeProps(props, {
|
||||
get children() {
|
||||
return value()
|
||||
},
|
||||
})
|
||||
return view(merged as TuiSlotProps<Name>)
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string, object>> {
|
||||
if (!isRecord(value)) return false
|
||||
|
||||
@@ -1278,7 +1278,8 @@ export function Session() {
|
||||
<NetworkPrompt request={network()[0]} />
|
||||
</Show>
|
||||
{/* kilocode_change end */}
|
||||
<Show when={visible()}>
|
||||
{/* kilocode_change start */}
|
||||
<Show when={!session()?.parentID}>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="session_prompt"
|
||||
mode="replace"
|
||||
@@ -1300,6 +1301,7 @@ export function Session() {
|
||||
/>
|
||||
</TuiPluginRuntime.Slot>
|
||||
</Show>
|
||||
{/* kilocode_change end */}
|
||||
</box>
|
||||
</Show>
|
||||
<Toast />
|
||||
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
declare module "jschardet" {
|
||||
export interface Result {
|
||||
encoding?: string
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
export function detect(input: ArrayLike<number>): Result
|
||||
|
||||
const api: {
|
||||
detect(input: ArrayLike<number>): Result
|
||||
}
|
||||
|
||||
export default api
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { readFile, writeFile, mkdir } from "fs/promises"
|
||||
import { readFileSync } from "fs"
|
||||
import { dirname } from "path"
|
||||
import jschardet from "jschardet"
|
||||
import iconv from "iconv-lite"
|
||||
|
||||
/**
|
||||
* Text encoding detection and preservation for tool file I/O.
|
||||
*
|
||||
* Supported:
|
||||
* - UTF-8 (with or without BOM)
|
||||
* - UTF-16 LE/BE with BOM (detected by jschardet)
|
||||
* - Legacy Latin and CJK encodings (detected by jschardet)
|
||||
*
|
||||
* Not supported:
|
||||
* - UTF-16 without BOM (ambiguous, rare)
|
||||
* - UTF-32 (extremely rare)
|
||||
*
|
||||
* Detection strategy:
|
||||
* 1. If the bytes are valid UTF-8, treat as UTF-8 (tracking the presence of a
|
||||
* BOM so it can be written back).
|
||||
* 2. Otherwise, trust jschardet.
|
||||
*
|
||||
* iconv-lite's UTF codecs strip BOMs on decode and do not emit them on encode,
|
||||
* so UTF BOMs are handled explicitly in {@link encode} to round-trip cleanly.
|
||||
*/
|
||||
export namespace Encoding {
|
||||
export const DEFAULT = "utf-8"
|
||||
/**
|
||||
* Synthetic label for UTF-8 files that start with a BOM. iconv-lite's utf-8
|
||||
* codec always strips BOMs on decode and never emits one on encode, so we
|
||||
* track the "with BOM" case explicitly to round-trip it faithfully.
|
||||
*/
|
||||
export const UTF8_BOM = "utf-8-bom"
|
||||
const UTF8_BOM_BYTES = Buffer.from([0xef, 0xbb, 0xbf])
|
||||
|
||||
function hasUtf8Bom(bytes: Buffer): boolean {
|
||||
return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
|
||||
}
|
||||
|
||||
/** True if `bytes[0..limit]` starts with a UTF-16 LE or BE byte-order mark. */
|
||||
export function hasUtf16Bom(bytes: Buffer, limit = bytes.length): boolean {
|
||||
if (limit < 2) return false
|
||||
return (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff)
|
||||
}
|
||||
|
||||
/** Remap jschardet labels to iconv-lite compatible names. */
|
||||
function normalize(name: string): string {
|
||||
const lower = name.toLowerCase().replace(/[^a-z0-9]/g, "")
|
||||
const map: Record<string, string> = {
|
||||
utf8: "utf-8",
|
||||
utf16le: "utf-16le",
|
||||
utf16be: "utf-16be",
|
||||
ascii: "utf-8",
|
||||
iso88591: "iso-8859-1",
|
||||
iso88592: "iso-8859-2",
|
||||
iso88595: "iso-8859-5",
|
||||
iso88597: "iso-8859-7",
|
||||
iso88598: "iso-8859-8",
|
||||
iso88599: "iso-8859-9",
|
||||
windows1250: "windows-1250",
|
||||
windows1251: "windows-1251",
|
||||
windows1252: "windows-1252",
|
||||
windows1253: "windows-1253",
|
||||
windows1255: "windows-1255",
|
||||
shiftjis: "Shift_JIS",
|
||||
eucjp: "euc-jp",
|
||||
iso2022jp: "iso-2022-jp",
|
||||
euckr: "euc-kr",
|
||||
iso2022kr: "iso-2022-kr",
|
||||
big5: "big5",
|
||||
gb2312: "gb2312",
|
||||
gb18030: "gb18030",
|
||||
koi8r: "koi8-r",
|
||||
maccyrillic: "x-mac-cyrillic",
|
||||
ibm855: "cp855",
|
||||
ibm866: "cp866",
|
||||
tis620: "tis-620",
|
||||
}
|
||||
return map[lower] ?? name
|
||||
}
|
||||
|
||||
function isUtf8(bytes: Buffer): boolean {
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function detect(bytes: Buffer): string {
|
||||
if (bytes.length === 0) return DEFAULT
|
||||
if (isUtf8(bytes)) return hasUtf8Bom(bytes) ? UTF8_BOM : DEFAULT
|
||||
const result = jschardet.detect(bytes)
|
||||
if (!result.encoding) return DEFAULT
|
||||
const enc = normalize(result.encoding)
|
||||
// Reject unsupported Unicode encodings (UTF-32 and anything iconv-lite cannot decode)
|
||||
if (enc.toLowerCase().startsWith("utf-32")) return DEFAULT
|
||||
if (!iconv.encodingExists(enc)) return DEFAULT
|
||||
return enc
|
||||
}
|
||||
|
||||
export function decode(bytes: Buffer, encoding: string): string {
|
||||
if (encoding === UTF8_BOM) return iconv.decode(bytes, "utf-8")
|
||||
return iconv.decode(bytes, encoding)
|
||||
}
|
||||
|
||||
export function encode(text: string, encoding: string): Buffer {
|
||||
// iconv-lite's UTF codecs strip/ignore BOMs, but we support "UTF-X with BOM"
|
||||
// as a distinct variant. Prepend the BOM manually so round-tripping keeps
|
||||
// the original byte signature intact. Strip a leading U+FEFF from `text`
|
||||
// first so we never emit a double BOM when the decoded text already
|
||||
// contains one (e.g. if a tool round-trips content verbatim).
|
||||
const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
||||
if (encoding === UTF8_BOM) return Buffer.concat([UTF8_BOM_BYTES, iconv.encode(body, "utf-8")])
|
||||
const lower = encoding.toLowerCase()
|
||||
if (lower === "utf-16le") return Buffer.concat([Buffer.from([0xff, 0xfe]), iconv.encode(body, encoding)])
|
||||
if (lower === "utf-16be") return Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode(body, encoding)])
|
||||
return iconv.encode(text, encoding)
|
||||
}
|
||||
|
||||
/** Read a file, detecting its encoding. */
|
||||
export async function read(path: string): Promise<{ text: string; encoding: string }> {
|
||||
const bytes = await readFile(path)
|
||||
const encoding = detect(bytes)
|
||||
return { text: decode(bytes, encoding), encoding }
|
||||
}
|
||||
|
||||
/** Synchronous read, detecting encoding. */
|
||||
export function readSync(path: string): { text: string; encoding: string } {
|
||||
const bytes = readFileSync(path)
|
||||
const encoding = detect(bytes)
|
||||
return { text: decode(bytes, encoding), encoding }
|
||||
}
|
||||
|
||||
/** Write text, ensuring parent directory exists, using the given encoding. */
|
||||
export async function write(path: string, text: string, encoding: string = DEFAULT): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, encode(text, encoding))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { Log } from "@/util"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue"
|
||||
|
||||
/**
|
||||
* Kilo-specific helpers for the shared `@/question` module.
|
||||
*
|
||||
* Extracted here so the upstream file keeps just the import, an Interface entry
|
||||
* for `dismissAll`, and one-liner calls at the use sites — minimising the
|
||||
* surface area that conflicts with upstream.
|
||||
*/
|
||||
export namespace KiloQuestion {
|
||||
const log = Log.create({ service: "question" })
|
||||
|
||||
/** Minimal entry shape both helpers need; matches `PendingEntry` in `@/question`. */
|
||||
type Entry = {
|
||||
info: { id: unknown; sessionID: SessionID }
|
||||
deferred: Deferred.Deferred<any, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for `Question.dismissAll`: dismisses every pending question on a
|
||||
* session so a new prompt can unblock an in-flight tool waiting on user
|
||||
* input. Mirrors `Suggestion.dismissAll` so both read the same way at the
|
||||
* callsite.
|
||||
*
|
||||
* The caller provides a `publishRejected` callback (closed over the already-
|
||||
* resolved `Bus.Service` in the Question layer) and an error factory so this
|
||||
* helper stays free of any `@/question` import and dodges a circular dep.
|
||||
*/
|
||||
export const makeDismissAll =
|
||||
<ID, PE extends Entry>(args: {
|
||||
state: InstanceState.InstanceState<{ pending: Map<ID, PE> }>
|
||||
publishRejected: (entry: PE) => Effect.Effect<void>
|
||||
makeError: () => PE["deferred"] extends Deferred.Deferred<any, infer E> ? E : never
|
||||
}) =>
|
||||
(sessionID: SessionID) =>
|
||||
Effect.gen(function* () {
|
||||
const pending = (yield* InstanceState.get(args.state)).pending
|
||||
for (const [id, entry] of Array.from(pending.entries())) {
|
||||
if (entry.info.sessionID !== sessionID) continue
|
||||
pending.delete(id)
|
||||
log.info("dismissed", { requestID: id })
|
||||
yield* args.publishRejected(entry)
|
||||
yield* Deferred.fail(entry.deferred, args.makeError())
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Auto-dismiss when a newer prompt is already queued on this session — a
|
||||
* tool that calls `Question.ask` after the queue event would otherwise block
|
||||
* the run while the user waits for their queued prompt to take over.
|
||||
*/
|
||||
export const guardFollowup = <E>(sessionID: SessionID, makeError: () => E) =>
|
||||
Effect.gen(function* () {
|
||||
if (!KiloSessionPromptQueue.hasFollowup(sessionID)) return
|
||||
log.info("auto-dismissed — followup queued", { sessionID })
|
||||
return yield* Effect.fail(makeError())
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { MessageV2 } from "@/session/message-v2"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
|
||||
type Slot = {
|
||||
readonly seq: number
|
||||
readonly version: number
|
||||
readonly previous: Promise<void>
|
||||
readonly done: PromiseWithResolvers<void>
|
||||
@@ -18,6 +19,13 @@ export namespace KiloSessionPromptQueue {
|
||||
const tails = new Map<SessionID, Promise<void>>()
|
||||
const versions = new Map<SessionID, number>()
|
||||
const targets = new Map<SessionID, Target>()
|
||||
// Monotonic arrival counter per session. latest holds the seq of the most
|
||||
// recently enqueued slot; activeSince snapshots latest at the moment the
|
||||
// currently running slot actually started. hasFollowup returns true only when
|
||||
// a newer slot was enqueued after the active one began running.
|
||||
const latest = new Map<SessionID, number>()
|
||||
const activeSince = new Map<SessionID, number>()
|
||||
let seq = 0
|
||||
|
||||
const version = (sessionID: SessionID) => versions.get(sessionID) ?? 0
|
||||
const settle = (promise: Promise<void>) =>
|
||||
@@ -45,6 +53,18 @@ export namespace KiloSessionPromptQueue {
|
||||
targets.set(sessionID, { base: current.base, extras })
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a newer prompt was enqueued after the currently running slot
|
||||
* began. runLoop calls this between LLM steps to break out so the next
|
||||
* queued prompt can take over without starting another LLM round-trip for
|
||||
* the now-superseded turn.
|
||||
*/
|
||||
export function hasFollowup(sessionID: SessionID): boolean {
|
||||
const l = latest.get(sessionID) ?? 0
|
||||
const a = activeSince.get(sessionID) ?? 0
|
||||
return l > a
|
||||
}
|
||||
|
||||
export function scope(sessionID: SessionID, messages: MessageV2.WithParts[]) {
|
||||
const target = targets.get(sessionID)
|
||||
if (!target) return messages
|
||||
@@ -90,17 +110,23 @@ export namespace KiloSessionPromptQueue {
|
||||
): Effect.Effect<A, E> {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const mine = ++seq
|
||||
latest.set(sessionID, mine)
|
||||
const previous = tails.get(sessionID) ?? Promise.resolve()
|
||||
const done = Promise.withResolvers<void>()
|
||||
// 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
|
||||
return { seq: mine, version: version(sessionID), previous, done, tail } satisfies Slot
|
||||
}),
|
||||
(slot) =>
|
||||
Effect.promise(() => settle(slot.previous)).pipe(
|
||||
Effect.flatMap(() => {
|
||||
if (slot.version !== version(sessionID)) return cancelled
|
||||
// Snapshot the latest seq at the moment this slot actually starts
|
||||
// running. hasFollowup compares against this value so the slot only
|
||||
// breaks when something newer than itself arrives.
|
||||
activeSince.set(sessionID, latest.get(sessionID) ?? slot.seq)
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
targets.set(sessionID, { base: target, extras: new Set() })
|
||||
@@ -120,6 +146,8 @@ export namespace KiloSessionPromptQueue {
|
||||
tails.delete(sessionID)
|
||||
versions.delete(sessionID)
|
||||
targets.delete(sessionID)
|
||||
latest.delete(sessionID)
|
||||
activeSince.delete(sessionID)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,8 +17,14 @@ You are Kilo, a highly skilled software engineer with extensive knowledge in man
|
||||
|
||||
- Use the `question` tool only when you need an actual answer from the user.
|
||||
- If the `suggest` tool is available, use it ONLY to offer a local code review — never for other actions like committing, pushing, running tests, or any other next step.
|
||||
- When you have completed implementation work and you are at least 90% confident the task is done, use `suggest` to offer a code review of uncommitted changes.
|
||||
- Only suggest review when the user's request appears fully addressed. Do not suggest it after every edit or partial implementation turn.
|
||||
- Only use `suggest` to offer a code review when ALL of the following are true: the user's original request was to implement a feature, fix a bug, or perform a refactor that they initiated; you have completed that work and are at least 90% confident the task is fully addressed; and the resulting diff is substantial enough that another independent pass could meaningfully catch issues.
|
||||
- Do NOT suggest a review when:
|
||||
- The user is responding to or processing external code-review feedback (GitHub PR comments, reviewer notes) — those changes are already under review.
|
||||
- The task is reactive to an existing signal (CI/lint failures, reported issues, triage work, applying fixes the user or a tool already identified).
|
||||
- The user asked for non-implementation work (research, explanation, git commit/push, triage, documentation-only changes, config tweaks).
|
||||
- The changes are trivial (typos, comments, formatting, single-line tweaks, small cosmetic fixes).
|
||||
- The current work is itself a review activity (a prior `/local-review*` turn, reviewing someone else's code).
|
||||
- Do not suggest it after every edit or partial implementation turn.
|
||||
- Do not repeat a review suggestion that was already dismissed in this conversation.
|
||||
- Keep suggestion text concise, use at most 1-2 actions, and make each accepted action prompt self-contained.
|
||||
- When suggesting a code review, choose the right command for the action prompt:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Bus } from "../../bus"
|
||||
import { BusEvent } from "../../bus/bus-event"
|
||||
import { Identifier } from "../../id/id"
|
||||
import { SessionID } from "../../session/schema"
|
||||
import { Log } from "../../util"
|
||||
import z from "zod"
|
||||
import { KiloSessionPromptQueue } from "../session/prompt-queue"
|
||||
|
||||
export namespace Suggestion {
|
||||
const log = Log.create({ service: "suggestion" })
|
||||
@@ -95,6 +97,14 @@ export namespace Suggestion {
|
||||
blocking?: boolean
|
||||
tool?: { messageID: string; callID: string }
|
||||
}): Promise<Action> {
|
||||
// Auto-dismiss if a newer prompt is already queued on this session.
|
||||
// Synchronous check immediately before the pending set, so there's no
|
||||
// interleaving with dismissAll called from SessionPrompt.prompt.
|
||||
if (KiloSessionPromptQueue.hasFollowup(SessionID.make(input.sessionID))) {
|
||||
log.info("auto-dismissed — followup queued", { sessionID: input.sessionID })
|
||||
throw new DismissedError()
|
||||
}
|
||||
|
||||
const s = { pending }
|
||||
const id = Identifier.ascending("suggestion")
|
||||
|
||||
|
||||
@@ -14,6 +14,16 @@ Guidelines:
|
||||
- Make each action prompt self-contained so it can be injected as a synthetic user message
|
||||
- If you need a real answer from the user, use the `question` tool instead
|
||||
|
||||
When to suggest a review:
|
||||
- Only when the user's original request was to implement a feature, fix a bug, or perform a refactor that they initiated, AND the resulting diff is substantial enough that another independent pass could meaningfully catch issues
|
||||
|
||||
Do NOT suggest a review when:
|
||||
- The user is responding to or processing external code-review feedback (GitHub PR comments, reviewer notes) — those changes are already under review
|
||||
- The task is reactive to an existing signal (CI/lint failures, reported issues, triage work, applying fixes the user or a tool already identified)
|
||||
- The user asked for non-implementation work (research, explanation, git commit/push, triage, documentation-only changes, config tweaks)
|
||||
- The changes are trivial (typos, comments, formatting, single-line tweaks, small cosmetic fixes)
|
||||
- The current work is itself a review activity (a prior `/local-review*` turn, reviewing someone else's code)
|
||||
|
||||
Choosing the right review command for the action prompt:
|
||||
- Use `/local-review-uncommitted` as the action prompt for uncommitted working-tree changes (staged, unstaged, and untracked files)
|
||||
- Use `/local-review` as the action prompt for committed branch-level changes
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Effect } from "effect"
|
||||
import { Encoding } from "../encoding"
|
||||
|
||||
/**
|
||||
* Effect wrappers around {@link Encoding.read} and {@link Encoding.write} so
|
||||
* tool code can preserve file encoding without leaking Node/async boilerplate
|
||||
* into each call site. Uses {@link Effect.tryPromise} so I/O failures surface
|
||||
* as typed errors that can be recovered with `.pipe(Effect.catch(...))`.
|
||||
*/
|
||||
export namespace EncodedIO {
|
||||
const wrap = (cause: unknown) => (cause instanceof Error ? cause : new Error(String(cause)))
|
||||
|
||||
export const read = (path: string) => Effect.tryPromise({ try: () => Encoding.read(path), catch: wrap })
|
||||
|
||||
export const write = (path: string, text: string, encoding: string = Encoding.DEFAULT) =>
|
||||
Effect.tryPromise({ try: () => Encoding.write(path, text, encoding), catch: wrap })
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Effect } from "effect"
|
||||
import { Question } from "@/question"
|
||||
|
||||
/**
|
||||
* Helpers for the shared `@/tool/question` tool that surface a dismissed-question
|
||||
* outcome (from `Question.dismissAll` when a new prompt arrives mid-question) as
|
||||
* a normal tool result instead of letting `Effect.orDie` turn the
|
||||
* `QuestionRejectedError` into a defect that kills the in-flight stream.
|
||||
*
|
||||
* Extracted here so the shared tool file keeps just a one-liner pipe plus an
|
||||
* early return, minimising the surface area that conflicts with upstream.
|
||||
*/
|
||||
export namespace KiloQuestionTool {
|
||||
const DISMISSED = "dismissed" as const
|
||||
type Dismissed = typeof DISMISSED
|
||||
|
||||
export const catchDismissed = <A, E, R>(eff: Effect.Effect<A, E | Question.RejectedError, R>) =>
|
||||
eff.pipe(Effect.catchTag("QuestionRejectedError", () => Effect.succeed<Dismissed>(DISMISSED)))
|
||||
|
||||
export const isDismissed = (v: unknown): v is Dismissed => v === DISMISSED
|
||||
|
||||
export const dismissedResult = () => ({
|
||||
title: "Question dismissed",
|
||||
output: "User dismissed the question.",
|
||||
metadata: { answers: [] as ReadonlyArray<Question.Answer>, dismissed: true as const },
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { readFileSync } from "fs"
|
||||
import { Log } from "../util"
|
||||
import { Encoding } from "../kilocode/encoding" // kilocode_change
|
||||
|
||||
const log = Log.create({ service: "patch" })
|
||||
|
||||
@@ -305,13 +305,19 @@ export function maybeParseApplyPatch(
|
||||
interface ApplyPatchFileUpdate {
|
||||
unified_diff: string
|
||||
content: string
|
||||
encoding: string // kilocode_change
|
||||
}
|
||||
|
||||
export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
|
||||
// Read original file content
|
||||
let originalContent: string
|
||||
let encoding: string // kilocode_change - track detected encoding for round-trip write
|
||||
try {
|
||||
originalContent = readFileSync(filePath, "utf-8")
|
||||
// kilocode_change start - encoding-aware read replaces readFileSync(filePath, "utf-8")
|
||||
const result = Encoding.readSync(filePath)
|
||||
originalContent = result.text
|
||||
encoding = result.encoding
|
||||
// kilocode_change end
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error })
|
||||
}
|
||||
@@ -339,6 +345,7 @@ export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFile
|
||||
return {
|
||||
unified_diff: unifiedDiff,
|
||||
content: newContent,
|
||||
encoding, // kilocode_change - include detected encoding for round-trip write
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,13 +533,7 @@ export async function applyHunksToFiles(hunks: Hunk[]): Promise<AffectedPaths> {
|
||||
for (const hunk of hunks) {
|
||||
switch (hunk.type) {
|
||||
case "add":
|
||||
// Create parent directories
|
||||
const addDir = path.dirname(hunk.path)
|
||||
if (addDir !== "." && addDir !== "/") {
|
||||
await fs.mkdir(addDir, { recursive: true })
|
||||
}
|
||||
|
||||
await fs.writeFile(hunk.path, hunk.contents, "utf-8")
|
||||
await Encoding.write(hunk.path, hunk.contents) // kilocode_change - encoding-aware write (mkdirs)
|
||||
added.push(hunk.path)
|
||||
log.info(`Added file: ${hunk.path}`)
|
||||
break
|
||||
@@ -548,18 +549,13 @@ export async function applyHunksToFiles(hunks: Hunk[]): Promise<AffectedPaths> {
|
||||
|
||||
if (hunk.move_path) {
|
||||
// Handle file move
|
||||
const moveDir = path.dirname(hunk.move_path)
|
||||
if (moveDir !== "." && moveDir !== "/") {
|
||||
await fs.mkdir(moveDir, { recursive: true })
|
||||
}
|
||||
|
||||
await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8")
|
||||
await Encoding.write(hunk.move_path, fileUpdate.content, fileUpdate.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces fs.mkdir + fs.writeFile
|
||||
await fs.unlink(hunk.path)
|
||||
modified.push(hunk.move_path)
|
||||
log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
|
||||
} else {
|
||||
// Regular update
|
||||
await fs.writeFile(hunk.path, fileUpdate.content, "utf-8")
|
||||
await Encoding.write(hunk.path, fileUpdate.content, fileUpdate.encoding) // kilocode_change - encoding-aware write replaces fs.writeFile
|
||||
modified.push(hunk.path)
|
||||
log.info(`Updated file: ${hunk.path}`)
|
||||
}
|
||||
@@ -624,7 +620,7 @@ export async function maybeParseApplyPatchVerified(
|
||||
// For delete, we need to read the current content
|
||||
const deletePath = path.resolve(effectiveCwd, hunk.path)
|
||||
try {
|
||||
const content = await fs.readFile(deletePath, "utf-8")
|
||||
const content = (await Encoding.read(deletePath)).text // kilocode_change - encoding-aware read
|
||||
changes.set(resolvedPath, {
|
||||
type: "delete",
|
||||
content,
|
||||
|
||||
@@ -380,6 +380,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.5",
|
||||
])
|
||||
for (const [modelId, model] of Object.entries(provider.models)) {
|
||||
if (modelId.includes("codex")) continue
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Log } from "@/util"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { QuestionID } from "./schema"
|
||||
import { makeRuntime } from "@/effect/run-service" // kilocode_change
|
||||
import { KiloQuestion } from "@/kilocode/question" // kilocode_change
|
||||
|
||||
const log = Log.create({ service: "question" })
|
||||
|
||||
@@ -148,6 +149,7 @@ export interface Interface {
|
||||
readonly reply: (input: { requestID: QuestionID; answers: ReadonlyArray<Answer> }) => Effect.Effect<void>
|
||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||
readonly dismissAll: (sessionID: SessionID) => Effect.Effect<void> // kilocode_change
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Question") {}
|
||||
@@ -193,6 +195,11 @@ export const layer = Layer.effect(
|
||||
blocking: input.blocking, // kilocode_change
|
||||
tool: input.tool,
|
||||
})
|
||||
|
||||
// kilocode_change start
|
||||
yield* KiloQuestion.guardFollowup(input.sessionID, () => new RejectedError())
|
||||
// kilocode_change end
|
||||
|
||||
pending.set(id, { info, deferred })
|
||||
yield* bus.publish(Event.Asked, info)
|
||||
|
||||
@@ -245,7 +252,16 @@ export const layer = Layer.effect(
|
||||
return Array.from(pending.values(), (x) => x.info)
|
||||
})
|
||||
|
||||
return Service.of({ ask, reply, reject, list })
|
||||
// kilocode_change start - body lives in @/kilocode/question/KiloQuestion.makeDismissAll
|
||||
const dismissAll = KiloQuestion.makeDismissAll({
|
||||
state,
|
||||
publishRejected: (entry) =>
|
||||
bus.publish(Event.Rejected, { sessionID: entry.info.sessionID, requestID: entry.info.id }),
|
||||
makeError: () => new RejectedError(),
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
return Service.of({ ask, reply, reject, list, dismissAll }) // kilocode_change
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -257,6 +273,7 @@ export const list = () => runPromise((svc) => svc.list())
|
||||
export const ask = (input: Parameters<Interface["ask"]>[0]) => runPromise((svc) => svc.ask(input))
|
||||
export const reply = (input: Parameters<Interface["reply"]>[0]) => runPromise((svc) => svc.reply(input))
|
||||
export const reject = (requestID: QuestionID) => runPromise((svc) => svc.reject(requestID))
|
||||
export const dismissAll = (sessionID: string) => runPromise((svc) => svc.dismissAll(SessionID.make(sessionID)))
|
||||
// kilocode_change end
|
||||
|
||||
export * as Question from "."
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { Suggestion } from "@/kilocode/suggestion" // kilocode_change
|
||||
import { Question } from "@/question" // kilocode_change
|
||||
import z from "zod"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
@@ -1285,6 +1286,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)
|
||||
|
||||
@@ -1297,12 +1299,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
|
||||
}
|
||||
|
||||
if (input.noReply === true) return message
|
||||
// kilocode_change start — dismiss pending suggestions so a previous loop
|
||||
// blocked on a suggestion can settle before the queue runs the next prompt
|
||||
// kilocode_change start — unblock tools waiting on user input so any in-flight
|
||||
// handle.process can return. Adding a new user message is the signal that any
|
||||
// pending tool prompt is superseded, so we dismiss even on the noReply path.
|
||||
// Critically we never cancel the in-flight fiber here — that would abort the
|
||||
// streamText call mid-tokens and cut off the assistant reply. The enqueue call
|
||||
// below serializes this prompt after the current turn's current LLM step, and
|
||||
// runLoop checks hasFollowup between steps to break out once it has been
|
||||
// enqueued during the turn.
|
||||
yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID))
|
||||
// kilocode_change end
|
||||
// kilocode_change start - serialize follow-up loops via queue
|
||||
yield* Effect.promise(() => Question.dismissAll(input.sessionID))
|
||||
if (input.noReply === true) return message
|
||||
return yield* KiloSessionPromptQueue.enqueue(
|
||||
input.sessionID,
|
||||
message.info.id,
|
||||
@@ -1312,6 +1319,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
// kilocode_change end
|
||||
},
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
// kilocode_change start - retry when cancel races before shellImpl writes messages
|
||||
@@ -1604,6 +1612,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
overflow: !handle.message.finish,
|
||||
})
|
||||
}
|
||||
// kilocode_change start — break out so a newer queued prompt can take over
|
||||
// instead of starting another LLM step for the now-superseded turn. The
|
||||
// current handle.process has fully drained (tokens + inline tool calls) by
|
||||
// the time we get here, so nothing is cut off.
|
||||
if (KiloSessionPromptQueue.hasFollowup(sessionID)) {
|
||||
closeReasons.set(sessionID, "interrupted")
|
||||
return "break" as const
|
||||
}
|
||||
// kilocode_change end
|
||||
return "continue" as const
|
||||
}).pipe(Effect.ensuring(instruction.clear(handle.message.id)))
|
||||
if (outcome === "break") break
|
||||
|
||||
@@ -15,6 +15,7 @@ import DESCRIPTION from "./apply_patch.txt"
|
||||
import { File } from "../file"
|
||||
import { filterDiagnostics } from "./diagnostics" // kilocode_change
|
||||
import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change
|
||||
import { EncodedIO } from "../kilocode/tool/encoded-io" // kilocode_change
|
||||
import { Format } from "../format"
|
||||
|
||||
const PatchParams = z.object({
|
||||
@@ -61,6 +62,7 @@ export const ApplyPatchTool = Tool.define(
|
||||
diff: string
|
||||
additions: number
|
||||
deletions: number
|
||||
encoding: string // kilocode_change - preserved per-file encoding
|
||||
}> = []
|
||||
|
||||
let totalDiff = ""
|
||||
@@ -91,6 +93,7 @@ export const ApplyPatchTool = Tool.define(
|
||||
diff,
|
||||
additions,
|
||||
deletions,
|
||||
encoding: "utf-8", // kilocode_change - new files default to utf-8
|
||||
})
|
||||
|
||||
totalDiff += diff + "\n"
|
||||
@@ -106,13 +109,15 @@ export const ApplyPatchTool = Tool.define(
|
||||
)
|
||||
}
|
||||
|
||||
const oldContent = yield* afs.readFileString(filePath)
|
||||
const oldContent = (yield* EncodedIO.read(filePath)).text // kilocode_change - encoding-aware read
|
||||
let newContent = oldContent
|
||||
let encoding: string // kilocode_change - filled in by the patch helper below
|
||||
|
||||
// Apply the update chunks to get new content
|
||||
try {
|
||||
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
|
||||
newContent = fileUpdate.content
|
||||
encoding = fileUpdate.encoding // kilocode_change
|
||||
} catch (error) {
|
||||
return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
|
||||
}
|
||||
@@ -138,6 +143,7 @@ export const ApplyPatchTool = Tool.define(
|
||||
diff,
|
||||
additions,
|
||||
deletions,
|
||||
encoding, // kilocode_change
|
||||
})
|
||||
|
||||
totalDiff += diff + "\n"
|
||||
@@ -145,17 +151,18 @@ export const ApplyPatchTool = Tool.define(
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
const contentToDelete = yield* afs
|
||||
.readFileString(filePath)
|
||||
.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.fail(
|
||||
new Error(
|
||||
`apply_patch verification failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
// kilocode_change start - encoding-aware read
|
||||
const deleteRead = yield* EncodedIO.read(filePath).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.fail(
|
||||
new Error(
|
||||
`apply_patch verification failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
const contentToDelete = deleteRead.text
|
||||
// kilocode_change end
|
||||
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
|
||||
|
||||
const deletions = contentToDelete.split("\n").length
|
||||
@@ -168,6 +175,7 @@ export const ApplyPatchTool = Tool.define(
|
||||
diff: deleteDiff,
|
||||
additions: 0,
|
||||
deletions,
|
||||
encoding: deleteRead.encoding, // kilocode_change
|
||||
})
|
||||
|
||||
totalDiff += deleteDiff + "\n"
|
||||
@@ -208,21 +216,19 @@ export const ApplyPatchTool = Tool.define(
|
||||
switch (change.type) {
|
||||
case "add":
|
||||
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||
|
||||
yield* afs.writeWithDirs(change.filePath, change.newContent)
|
||||
yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces afs.writeWithDirs
|
||||
updates.push({ file: change.filePath, event: "add" })
|
||||
break
|
||||
|
||||
case "update":
|
||||
yield* afs.writeWithDirs(change.filePath, change.newContent)
|
||||
yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) // kilocode_change - encoding-aware write replaces afs.writeWithDirs
|
||||
updates.push({ file: change.filePath, event: "change" })
|
||||
break
|
||||
|
||||
case "move":
|
||||
if (change.movePath) {
|
||||
// Create parent directories (recursive: true is safe on existing/root dirs)
|
||||
|
||||
yield* afs.writeWithDirs(change.movePath!, change.newContent)
|
||||
yield* EncodedIO.write(change.movePath!, change.newContent, change.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces afs.writeWithDirs
|
||||
yield* afs.remove(change.filePath)
|
||||
updates.push({ file: change.filePath, event: "unlink" })
|
||||
updates.push({ file: change.movePath, event: "add" })
|
||||
|
||||
@@ -20,6 +20,7 @@ import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { filterDiagnostics } from "./diagnostics" // kilocode_change
|
||||
import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change
|
||||
import { EncodedIO } from "../kilocode/tool/encoded-io" // kilocode_change
|
||||
|
||||
const MAX_DIFF_CONTENT = 500_000 // kilocode_change
|
||||
|
||||
@@ -96,7 +97,11 @@ export const EditTool = Tool.define(
|
||||
yield* Effect.gen(function* () {
|
||||
if (params.oldString === "") {
|
||||
const existed = yield* afs.existsSafe(filePath)
|
||||
if (existed) contentOld = yield* afs.readFileString(filePath) // kilocode_change
|
||||
// kilocode_change start - preserve file encoding on write
|
||||
const pre = existed ? yield* EncodedIO.read(filePath) : { text: "", encoding: "utf-8" }
|
||||
contentOld = pre.text
|
||||
const encoding = pre.encoding
|
||||
// kilocode_change end
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
cachedFilediff = buildFileDiff(filePath, contentOld, contentNew) // kilocode_change
|
||||
@@ -110,7 +115,7 @@ export const EditTool = Tool.define(
|
||||
filediff: cachedFilediff, // kilocode_change
|
||||
},
|
||||
})
|
||||
yield* afs.writeWithDirs(filePath, params.newString)
|
||||
yield* EncodedIO.write(filePath, params.newString, encoding) // kilocode_change - preserve encoding; replaces afs.writeWithDirs
|
||||
yield* format.file(filePath)
|
||||
yield* bus.publish(File.Event.Edited, { file: filePath })
|
||||
yield* bus.publish(FileWatcher.Event.Updated, {
|
||||
@@ -123,7 +128,11 @@ export const EditTool = Tool.define(
|
||||
const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info) throw new Error(`File ${filePath} not found`)
|
||||
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
|
||||
contentOld = yield* afs.readFileString(filePath)
|
||||
// kilocode_change start - preserve file encoding
|
||||
const pre = yield* EncodedIO.read(filePath)
|
||||
contentOld = pre.text
|
||||
const encoding = pre.encoding
|
||||
// kilocode_change end
|
||||
|
||||
const ending = detectLineEnding(contentOld)
|
||||
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
|
||||
@@ -151,14 +160,14 @@ export const EditTool = Tool.define(
|
||||
},
|
||||
})
|
||||
|
||||
yield* afs.writeWithDirs(filePath, contentNew)
|
||||
yield* EncodedIO.write(filePath, contentNew, encoding) // kilocode_change - preserve encoding; replaces afs.writeWithDirs
|
||||
yield* format.file(filePath)
|
||||
yield* bus.publish(File.Event.Edited, { file: filePath })
|
||||
yield* bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
})
|
||||
contentNew = yield* afs.readFileString(filePath)
|
||||
contentNew = (yield* EncodedIO.read(filePath)).text // kilocode_change - re-read via encoding-aware helper; replaces afs.readFileString
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(
|
||||
filePath,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Effect } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import { Question } from "../question"
|
||||
import DESCRIPTION from "./question.txt"
|
||||
import { KiloQuestionTool } from "@/kilocode/tool/question" // kilocode_change
|
||||
|
||||
const parameters = z.object({
|
||||
questions: z.array(Question.Prompt.zod).describe("Questions to ask"),
|
||||
@@ -10,6 +11,7 @@ const parameters = z.object({
|
||||
|
||||
type Metadata = {
|
||||
answers: ReadonlyArray<Question.Answer>
|
||||
dismissed?: boolean // kilocode_change
|
||||
}
|
||||
|
||||
export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
|
||||
@@ -22,11 +24,18 @@ export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Se
|
||||
parameters,
|
||||
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
Effect.gen(function* () {
|
||||
const answers = yield* question.ask({
|
||||
sessionID: ctx.sessionID,
|
||||
questions: params.questions,
|
||||
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
|
||||
})
|
||||
// kilocode_change start - surface Question.dismissAll's RejectedError as a normal
|
||||
// tool result via KiloQuestionTool helpers, so Effect.orDie below does not turn
|
||||
// it into a defect and kill the in-flight stream.
|
||||
const answers = yield* question
|
||||
.ask({
|
||||
sessionID: ctx.sessionID,
|
||||
questions: params.questions,
|
||||
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
|
||||
})
|
||||
.pipe(KiloQuestionTool.catchDismissed)
|
||||
if (KiloQuestionTool.isDismissed(answers)) return KiloQuestionTool.dismissedResult()
|
||||
// kilocode_change end
|
||||
|
||||
const formatted = params.questions
|
||||
.map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import z from "zod"
|
||||
import { Effect, Option, Scope } from "effect"
|
||||
import { createReadStream } from "fs"
|
||||
import { lstat } from "fs/promises" // kilocode_change
|
||||
import { open, lstat } from "fs/promises" // kilocode_change
|
||||
import * as path from "path"
|
||||
import { Readable } from "stream" // kilocode_change
|
||||
import { createInterface } from "readline"
|
||||
import * as Tool from "./tool"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
@@ -12,6 +12,10 @@ import { Instance } from "../project/instance"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { Instruction } from "../session/instruction"
|
||||
import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
||||
// kilocode_change start
|
||||
import { Encoding } from "../kilocode/encoding"
|
||||
import { readDirectoryFiles } from "../kilocode/tool/read-directory"
|
||||
// kilocode_change end
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
@@ -331,7 +335,10 @@ export const ReadTool = Tool.define(
|
||||
// kilocode_change start
|
||||
export async function lines(filepath: string, opts: { limit: number; offset: number }) {
|
||||
// kilocode_change end
|
||||
const stream = createReadStream(filepath, { encoding: "utf8" })
|
||||
// kilocode_change start - decode with detected encoding; replaces createReadStream(filepath, { encoding: "utf8" })
|
||||
const encoded = await Encoding.read(filepath)
|
||||
const stream = Readable.from([encoded.text])
|
||||
// kilocode_change end
|
||||
const rl = createInterface({
|
||||
input: stream,
|
||||
// Note: we use the crlfDelay option to recognize all instances of CR LF
|
||||
|
||||
@@ -15,6 +15,7 @@ import { trimDiff, buildFileDiff } from "./edit" // kilocode_change
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { filterDiagnostics } from "./diagnostics" // kilocode_change
|
||||
import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change
|
||||
import { EncodedIO } from "../kilocode/tool/encoded-io" // kilocode_change
|
||||
|
||||
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
|
||||
|
||||
@@ -40,7 +41,11 @@ export const WriteTool = Tool.define(
|
||||
yield* assertExternalDirectoryEffect(ctx, filepath)
|
||||
|
||||
const exists = yield* fs.existsSafe(filepath)
|
||||
const contentOld = exists ? yield* fs.readFileString(filepath) : ""
|
||||
// kilocode_change start - preserve file encoding on write
|
||||
const pre = exists ? yield* EncodedIO.read(filepath) : { text: "", encoding: "utf-8" }
|
||||
const contentOld = pre.text
|
||||
const encoding = pre.encoding
|
||||
// kilocode_change end
|
||||
|
||||
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
|
||||
const filediff = buildFileDiff(filepath, contentOld, params.content) // kilocode_change
|
||||
@@ -55,7 +60,7 @@ export const WriteTool = Tool.define(
|
||||
},
|
||||
})
|
||||
|
||||
yield* fs.writeWithDirs(filepath, params.content)
|
||||
yield* EncodedIO.write(filepath, params.content, encoding) // kilocode_change - preserve encoding; replaces fs.writeWithDirs
|
||||
yield* format.file(filepath)
|
||||
yield* bus.publish(File.Event.Edited, { file: filepath })
|
||||
yield* bus.publish(FileWatcher.Event.Updated, {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// kilocode_change - new file
|
||||
// Unit tests for the Encoding namespace. These complement tool-encoding.test.ts
|
||||
// by exercising detect/decode/encode/read/write/readSync directly, without
|
||||
// going through the Effect runtime, agent harness, or tool pipeline. They are
|
||||
// cheap, fast, and cover the internal branches (BOM handling, ASCII/UTF-8
|
||||
// normalization, jschardet fallback, unsupported encoding rejection) that the
|
||||
// integration tests cannot hit deterministically.
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import iconv from "iconv-lite"
|
||||
import { Encoding } from "../../src/kilocode/encoding"
|
||||
|
||||
const BOM = {
|
||||
utf8: Buffer.from([0xef, 0xbb, 0xbf]),
|
||||
utf16le: Buffer.from([0xff, 0xfe]),
|
||||
utf16be: Buffer.from([0xfe, 0xff]),
|
||||
}
|
||||
|
||||
async function tmp<T>(body: (dir: string) => Promise<T>): Promise<T> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-encoding-"))
|
||||
try {
|
||||
return await body(dir)
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe("Encoding.detect", () => {
|
||||
test("empty buffer falls back to utf-8", () => {
|
||||
expect(Encoding.detect(Buffer.alloc(0))).toBe(Encoding.DEFAULT)
|
||||
})
|
||||
|
||||
test("plain ASCII is normalized to utf-8 (not 'ascii')", () => {
|
||||
// jschardet reports "ascii" for pure-ASCII input; the namespace treats
|
||||
// that as UTF-8 because UTF-8 is an ASCII superset and iconv-lite doesn't
|
||||
// expose an "ascii" label that round-trips identically.
|
||||
expect(Encoding.detect(Buffer.from("plain ascii text\n"))).toBe("utf-8")
|
||||
})
|
||||
|
||||
test("valid UTF-8 without BOM detects as utf-8", () => {
|
||||
expect(Encoding.detect(Buffer.from("Hello — 世界", "utf-8"))).toBe("utf-8")
|
||||
})
|
||||
|
||||
test("UTF-8 with BOM is reported as the distinct utf-8-bom variant", () => {
|
||||
const bytes = Buffer.concat([BOM.utf8, Buffer.from("hello", "utf-8")])
|
||||
expect(Encoding.detect(bytes)).toBe(Encoding.UTF8_BOM)
|
||||
})
|
||||
|
||||
test("BOM-less UTF-8 containing multi-byte chars is not misdetected", () => {
|
||||
// Regression guard: bytes that are valid UTF-8 must skip the jschardet
|
||||
// branch. jschardet has been known to misfire on short CJK samples.
|
||||
expect(Encoding.detect(Buffer.from("한글 テスト 中文", "utf-8"))).toBe("utf-8")
|
||||
})
|
||||
|
||||
test("UTF-16 LE with BOM detects as utf-16le", () => {
|
||||
const bytes = Buffer.concat([BOM.utf16le, iconv.encode("hello world", "utf-16le")])
|
||||
expect(Encoding.detect(bytes)).toBe("utf-16le")
|
||||
})
|
||||
|
||||
test("UTF-16 BE with BOM detects as utf-16be", () => {
|
||||
const bytes = Buffer.concat([BOM.utf16be, iconv.encode("hello world", "utf-16be")])
|
||||
expect(Encoding.detect(bytes)).toBe("utf-16be")
|
||||
})
|
||||
|
||||
test("UTF-32 (detected by jschardet) is rejected and falls back to utf-8", () => {
|
||||
// Build a sample starting with a UTF-32 LE BOM. jschardet will report
|
||||
// UTF-32*; the namespace explicitly strips that because iconv-lite can't
|
||||
// round-trip it.
|
||||
const bytes = Buffer.concat([Buffer.from([0xff, 0xfe, 0x00, 0x00]), Buffer.alloc(32)])
|
||||
expect(Encoding.detect(bytes)).toBe(Encoding.DEFAULT)
|
||||
})
|
||||
|
||||
test("Shift_JIS bytes detect as Shift_JIS (case-insensitive, iconv-compatible label)", () => {
|
||||
const bytes = iconv.encode("こんにちは、世界!日本語のテストです。", "Shift_JIS")
|
||||
const detected = Encoding.detect(bytes)
|
||||
expect(detected.toLowerCase()).toBe("shift_jis")
|
||||
// The returned label must be accepted by iconv-lite so downstream decode
|
||||
// works without a second normalization step.
|
||||
expect(iconv.encodingExists(detected)).toBe(true)
|
||||
})
|
||||
|
||||
test("Windows-1251 bytes detect as windows-1251", () => {
|
||||
const bytes = iconv.encode("Привет, мир! Это тест кириллицы.", "windows-1251")
|
||||
expect(Encoding.detect(bytes)).toBe("windows-1251")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Encoding.decode / Encoding.encode", () => {
|
||||
const cases: Array<[string, string, string]> = [
|
||||
["utf-8", "utf-8", "Hello — £100"],
|
||||
["utf-8-bom synthetic label", Encoding.UTF8_BOM, "hello"],
|
||||
["utf-16le", "utf-16le", "Hello 世界"],
|
||||
["utf-16be", "utf-16be", "Hello 世界"],
|
||||
["Shift_JIS", "Shift_JIS", "日本語"],
|
||||
["windows-1251", "windows-1251", "Привет"],
|
||||
["gb2312", "gb2312", "你好"],
|
||||
["big5", "big5", "繁體"],
|
||||
["euc-kr", "euc-kr", "한국어"],
|
||||
["koi8-r", "koi8-r", "Привет"],
|
||||
["iso-8859-1", "iso-8859-1", "Hëllo Wörld"],
|
||||
]
|
||||
|
||||
for (const [label, encoding, text] of cases) {
|
||||
test(`round-trips ${label}`, () => {
|
||||
const bytes = Encoding.encode(text, encoding)
|
||||
expect(Encoding.decode(bytes, encoding)).toBe(text)
|
||||
})
|
||||
}
|
||||
|
||||
test("utf-8-bom encode emits exactly one BOM even if input starts with U+FEFF", () => {
|
||||
// Regression guard: writers may hand us text that was previously decoded
|
||||
// and still carries U+FEFF. The encoder must strip it to avoid doubling.
|
||||
const bytes = Encoding.encode("\uFEFFhello", Encoding.UTF8_BOM)
|
||||
expect(bytes.subarray(0, 3).equals(BOM.utf8)).toBe(true)
|
||||
expect(bytes.subarray(3, 6).equals(BOM.utf8)).toBe(false)
|
||||
expect(Encoding.decode(bytes, Encoding.UTF8_BOM)).toBe("hello")
|
||||
})
|
||||
|
||||
test("utf-16le encode emits exactly one BOM even if input starts with U+FEFF", () => {
|
||||
const bytes = Encoding.encode("\uFEFFhi", "utf-16le")
|
||||
expect(bytes.subarray(0, 2).equals(BOM.utf16le)).toBe(true)
|
||||
// Next two bytes must be the 'h' code unit (0x68 0x00), not another BOM.
|
||||
expect(bytes[2]).toBe(0x68)
|
||||
expect(bytes[3]).toBe(0x00)
|
||||
})
|
||||
|
||||
test("utf-16be encode emits exactly one BOM even if input starts with U+FEFF", () => {
|
||||
const bytes = Encoding.encode("\uFEFFhi", "utf-16be")
|
||||
expect(bytes.subarray(0, 2).equals(BOM.utf16be)).toBe(true)
|
||||
expect(bytes[2]).toBe(0x00)
|
||||
expect(bytes[3]).toBe(0x68)
|
||||
})
|
||||
|
||||
test("decode of utf-8-bom produces text without leading U+FEFF", () => {
|
||||
// iconv-lite's utf-8 codec is documented to strip BOMs; guard against
|
||||
// regressions if the underlying behaviour changes.
|
||||
const bytes = Buffer.concat([BOM.utf8, Buffer.from("abc", "utf-8")])
|
||||
expect(Encoding.decode(bytes, Encoding.UTF8_BOM)).toBe("abc")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Encoding.hasUtf16Bom", () => {
|
||||
test("detects LE BOM", () => {
|
||||
expect(Encoding.hasUtf16Bom(BOM.utf16le)).toBe(true)
|
||||
})
|
||||
test("detects BE BOM", () => {
|
||||
expect(Encoding.hasUtf16Bom(BOM.utf16be)).toBe(true)
|
||||
})
|
||||
test("returns false for UTF-8 BOM", () => {
|
||||
expect(Encoding.hasUtf16Bom(BOM.utf8)).toBe(false)
|
||||
})
|
||||
test("returns false for plain ASCII", () => {
|
||||
expect(Encoding.hasUtf16Bom(Buffer.from("ab"))).toBe(false)
|
||||
})
|
||||
test("respects an explicit limit smaller than the buffer", () => {
|
||||
// Passing limit<2 must treat the sample as too short to contain a BOM,
|
||||
// even if the underlying buffer starts with one. This matches the binary
|
||||
// detection call site which reads a bounded sample.
|
||||
expect(Encoding.hasUtf16Bom(BOM.utf16le, 1)).toBe(false)
|
||||
expect(Encoding.hasUtf16Bom(BOM.utf16le, 2)).toBe(true)
|
||||
})
|
||||
test("returns false for a one-byte buffer", () => {
|
||||
expect(Encoding.hasUtf16Bom(Buffer.from([0xff]))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Encoding.read / Encoding.readSync / Encoding.write", () => {
|
||||
test("read detects and decodes Shift_JIS asynchronously", async () => {
|
||||
await tmp(async (dir) => {
|
||||
const filepath = path.join(dir, "sj.txt")
|
||||
const text = "日本語テスト"
|
||||
await fs.writeFile(filepath, iconv.encode(text, "Shift_JIS"))
|
||||
const result = await Encoding.read(filepath)
|
||||
expect(result.text).toBe(text)
|
||||
expect(result.encoding.toLowerCase()).toBe("shift_jis")
|
||||
})
|
||||
})
|
||||
|
||||
test("readSync mirrors read for the same input", async () => {
|
||||
await tmp(async (dir) => {
|
||||
const filepath = path.join(dir, "sj.txt")
|
||||
const text = "日本語テスト"
|
||||
await fs.writeFile(filepath, iconv.encode(text, "Shift_JIS"))
|
||||
const sync = Encoding.readSync(filepath)
|
||||
const async_ = await Encoding.read(filepath)
|
||||
expect(sync).toEqual(async_)
|
||||
})
|
||||
})
|
||||
|
||||
test("read preserves UTF-8 BOM as a distinct encoding label", async () => {
|
||||
await tmp(async (dir) => {
|
||||
const filepath = path.join(dir, "bom.txt")
|
||||
await fs.writeFile(filepath, Buffer.concat([BOM.utf8, Buffer.from("hi", "utf-8")]))
|
||||
const result = await Encoding.read(filepath)
|
||||
expect(result.encoding).toBe(Encoding.UTF8_BOM)
|
||||
expect(result.text).toBe("hi")
|
||||
})
|
||||
})
|
||||
|
||||
test("write creates missing parent directories", async () => {
|
||||
await tmp(async (dir) => {
|
||||
const filepath = path.join(dir, "nested", "deeply", "file.txt")
|
||||
await Encoding.write(filepath, "hello", "utf-8")
|
||||
const bytes = await fs.readFile(filepath)
|
||||
expect(bytes.equals(Buffer.from("hello", "utf-8"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test("write defaults to utf-8 when encoding is omitted", async () => {
|
||||
await tmp(async (dir) => {
|
||||
const filepath = path.join(dir, "default.txt")
|
||||
await Encoding.write(filepath, "héllo")
|
||||
const bytes = await fs.readFile(filepath)
|
||||
expect(bytes.equals(Buffer.from("héllo", "utf-8"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test("write round-trips Shift_JIS bytes exactly", async () => {
|
||||
await tmp(async (dir) => {
|
||||
const filepath = path.join(dir, "sj.txt")
|
||||
const text = "日本語"
|
||||
await Encoding.write(filepath, text, "Shift_JIS")
|
||||
const bytes = await fs.readFile(filepath)
|
||||
expect(bytes.equals(iconv.encode(text, "Shift_JIS"))).toBe(true)
|
||||
// Must not be UTF-8 — regression guard against silent promotion.
|
||||
expect(bytes.equals(Buffer.from(text, "utf-8"))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("write + read round-trips utf-16le with BOM", async () => {
|
||||
await tmp(async (dir) => {
|
||||
const filepath = path.join(dir, "u16.txt")
|
||||
const text = "Hello 世界"
|
||||
await Encoding.write(filepath, text, "utf-16le")
|
||||
const bytes = await fs.readFile(filepath)
|
||||
expect(bytes.subarray(0, 2).equals(BOM.utf16le)).toBe(true)
|
||||
const result = await Encoding.read(filepath)
|
||||
expect(result.encoding).toBe("utf-16le")
|
||||
expect(result.text).toBe(text)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Contract test for prompt.ts Kilo-specific invariants.
|
||||
*
|
||||
* prompt.ts is a shared upstream file. PR #8988 added Suggestion.dismissAll
|
||||
* there with kilocode_change markers. An upstream merge that restructures
|
||||
* the prompt handling could silently remove this call — this test catches that.
|
||||
* prompt.ts is a shared upstream file. The Kilo-specific "new prompt unblocks
|
||||
* pending suggestions/questions then enqueues without cancelling the in-flight
|
||||
* stream" behaviour lives inside a kilocode_change block. An upstream merge
|
||||
* that restructures the prompt handling could silently remove these calls —
|
||||
* this test catches that.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test"
|
||||
@@ -18,18 +20,36 @@ describe("prompt.ts Kilo-specific invariants", () => {
|
||||
expect(content).toMatch(/import\s*\{[^}]*Suggestion[^}]*\}\s*from\s*["']@\/kilocode\/suggestion["']/)
|
||||
})
|
||||
|
||||
test("imports Question from the question module", () => {
|
||||
const content = fs.readFileSync(PROMPT_FILE, "utf-8")
|
||||
expect(content).toMatch(/import\s*\{[^}]*Question[^}]*\}\s*from\s*["']@\/question["']/)
|
||||
})
|
||||
|
||||
test("calls Suggestion.dismissAll before restarting the session loop", () => {
|
||||
const content = fs.readFileSync(PROMPT_FILE, "utf-8")
|
||||
expect(content).toContain("Suggestion.dismissAll")
|
||||
})
|
||||
|
||||
test("dismissAll runs before the prompt queue enqueues the new loop", () => {
|
||||
test("dismissAll for suggestions and questions runs before enqueue, without cancelling the in-flight fiber", () => {
|
||||
const content = fs.readFileSync(PROMPT_FILE, "utf-8")
|
||||
// dismissAll must precede KiloSessionPromptQueue.enqueue so a previous loop
|
||||
// blocked on a suggestion can settle before the queue starts the next prompt.
|
||||
// dismissAll for both suggestions and questions must precede the enqueue so
|
||||
// an in-flight handle.process blocked on a pending tool prompt can return.
|
||||
// Critically, the block must NOT call state.cancel or KiloSessionPromptQueue.reserve —
|
||||
// either of those would abort the running streamText mid-tokens, which was
|
||||
// the #9332 regression. Order: dismissAll(Suggestion) → dismissAll(Question) → enqueue.
|
||||
const block = content.match(
|
||||
/kilocode_change start[^\n]*dismiss[\s\S]*?Suggestion\.dismissAll[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/,
|
||||
/kilocode_change start[^\n]*unblock tools[\s\S]*?Suggestion\.dismissAll[\s\S]*?Question\.dismissAll[\s\S]*?KiloSessionPromptQueue\.enqueue/,
|
||||
)
|
||||
expect(block).not.toBeNull()
|
||||
expect(content).not.toMatch(/state\.cancel\(input\.sessionID\)/)
|
||||
expect(content).not.toMatch(/KiloSessionPromptQueue\.reserve/)
|
||||
})
|
||||
|
||||
test("runLoop breaks out between LLM steps when a newer prompt was enqueued", () => {
|
||||
const content = fs.readFileSync(PROMPT_FILE, "utf-8")
|
||||
// hasFollowup has to be checked inside runLoop so the current handle.process
|
||||
// finishes naturally (tokens + inline tool calls) and the next LLM step is
|
||||
// skipped when a follow-up is already queued.
|
||||
expect(content).toContain("KiloSessionPromptQueue.hasFollowup(sessionID)")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { KiloSessionPromptQueue } from "../../src/kilocode/session/prompt-queue"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Question } from "../../src/question"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("Question.dismissAll", () => {
|
||||
test("rejects pending asks for the target session and clears them", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sesA = SessionID.make("ses_a")
|
||||
const sesB = SessionID.make("ses_b")
|
||||
|
||||
const a1 = Question.ask({
|
||||
sessionID: sesA,
|
||||
questions: [
|
||||
{
|
||||
header: "Continue?",
|
||||
question: "Should I continue?",
|
||||
options: [
|
||||
{ label: "Yes", description: "Go" },
|
||||
{ label: "No", description: "Stop" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}).catch((err) => {
|
||||
if (err instanceof Question.RejectedError) return "rejected"
|
||||
throw err
|
||||
})
|
||||
|
||||
const a2 = Question.ask({
|
||||
sessionID: sesA,
|
||||
questions: [
|
||||
{
|
||||
header: "Retry?",
|
||||
question: "Try again?",
|
||||
options: [
|
||||
{ label: "Retry", description: "Retry" },
|
||||
{ label: "Cancel", description: "Cancel" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}).catch((err) => {
|
||||
if (err instanceof Question.RejectedError) return "rejected"
|
||||
throw err
|
||||
})
|
||||
|
||||
const b1 = Question.ask({
|
||||
sessionID: sesB,
|
||||
questions: [
|
||||
{
|
||||
header: "Deploy?",
|
||||
question: "Deploy now?",
|
||||
options: [
|
||||
{ label: "Ship", description: "Ship" },
|
||||
{ label: "Wait", description: "Wait" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}).catch((err) => {
|
||||
if (err instanceof Question.RejectedError) return "rejected-b"
|
||||
throw err
|
||||
})
|
||||
|
||||
// Wait for all three asks to register so we can dismiss them.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if ((await Question.list()).length >= 3) break
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
expect(await Question.list()).toHaveLength(3)
|
||||
|
||||
// Track whether B's promise settles.
|
||||
let settled = false
|
||||
b1.then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await Question.dismissAll("ses_a")
|
||||
|
||||
expect(await a1).toBe("rejected")
|
||||
expect(await a2).toBe("rejected")
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
expect(settled).toBe(false)
|
||||
|
||||
const remaining = await Question.list()
|
||||
expect(remaining).toHaveLength(1)
|
||||
expect(remaining[0]?.sessionID).toBe(sesB)
|
||||
|
||||
await Question.reject(remaining[0]!.id)
|
||||
expect(await b1).toBe("rejected-b")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("is a no-op when no questions exist", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Question.dismissAll("ses_missing")
|
||||
expect(await Question.list()).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("ask rejects immediately when a followup is queued on the session", async () => {
|
||||
// When a newer prompt has already been enqueued on the session, a tool
|
||||
// that subsequently calls Question.ask would otherwise block the run until
|
||||
// the user manually dismisses it. Verify the pre-emptive hasFollowup check
|
||||
// rejects with RejectedError before any pending entry is registered.
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("ses_auto_ask")
|
||||
const started = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
|
||||
// Slot 1 stays running so activeSince is pinned to its seq.
|
||||
const first = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
MessageID.make("message_ask_1"),
|
||||
Effect.gen(function* () {
|
||||
started.resolve()
|
||||
yield* Effect.promise(() => release.promise)
|
||||
return "first" as const
|
||||
}),
|
||||
Effect.succeed("first-cancelled" as const),
|
||||
),
|
||||
)
|
||||
await started.promise
|
||||
|
||||
// Slot 2 arrives while slot 1 is active — latest > activeSince.
|
||||
const second = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
MessageID.make("message_ask_2"),
|
||||
Effect.succeed("second" as const),
|
||||
Effect.succeed("second-cancelled" as const),
|
||||
),
|
||||
)
|
||||
await Bun.sleep(10)
|
||||
expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true)
|
||||
|
||||
await expect(
|
||||
Question.ask({
|
||||
sessionID,
|
||||
questions: [
|
||||
{
|
||||
header: "Continue?",
|
||||
question: "Should I continue?",
|
||||
options: [
|
||||
{ label: "Yes", description: "Go" },
|
||||
{ label: "No", description: "Stop" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(Question.RejectedError)
|
||||
expect(await Question.list()).toEqual([])
|
||||
|
||||
release.resolve()
|
||||
expect(await first).toBe("first")
|
||||
expect(await second).toBe("second")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,10 @@
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { KiloSessionPromptQueue } from "../../src/kilocode/session/prompt-queue"
|
||||
import { Suggestion } from "../../src/kilocode/suggestion"
|
||||
import { Question } from "../../src/question"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Session } from "../../src/session"
|
||||
@@ -221,11 +224,85 @@ describe("session prompt queue", () => {
|
||||
expect(ids[ids.length - 1]).toBe(injected)
|
||||
})
|
||||
|
||||
test("continues a queued prompt after the active run finishes", async () => {
|
||||
test("hasFollowup reports true only for prompts enqueued after the active slot started", async () => {
|
||||
const sessionID = SessionID.make("session_followup_semantics")
|
||||
const observed: Array<{ where: string; value: boolean }> = []
|
||||
const firstStarted = Promise.withResolvers<void>()
|
||||
const firstReleased = Promise.withResolvers<void>()
|
||||
const secondStarted = Promise.withResolvers<void>()
|
||||
const secondReleased = Promise.withResolvers<void>()
|
||||
|
||||
const first = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
MessageID.make("message_followup_1"),
|
||||
Effect.gen(function* () {
|
||||
observed.push({ where: "first:start", value: KiloSessionPromptQueue.hasFollowup(sessionID) })
|
||||
firstStarted.resolve()
|
||||
yield* Effect.promise(() => firstReleased.promise)
|
||||
observed.push({ where: "first:end", value: KiloSessionPromptQueue.hasFollowup(sessionID) })
|
||||
return "first"
|
||||
}),
|
||||
Effect.succeed("first-cancelled"),
|
||||
),
|
||||
)
|
||||
|
||||
await firstStarted.promise
|
||||
// msg1 is alone — nothing newer has arrived yet.
|
||||
expect(observed[0]?.value).toBe(false)
|
||||
|
||||
const second = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
MessageID.make("message_followup_2"),
|
||||
Effect.gen(function* () {
|
||||
observed.push({ where: "second:start", value: KiloSessionPromptQueue.hasFollowup(sessionID) })
|
||||
secondStarted.resolve()
|
||||
yield* Effect.promise(() => secondReleased.promise)
|
||||
return "second"
|
||||
}),
|
||||
Effect.succeed("second-cancelled"),
|
||||
),
|
||||
)
|
||||
|
||||
// Enqueueing msg2 while msg1 is still running must flip hasFollowup to true
|
||||
// for msg1's running slot.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true)
|
||||
|
||||
const third = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
MessageID.make("message_followup_3"),
|
||||
Effect.sync(() => {
|
||||
observed.push({ where: "third:start", value: KiloSessionPromptQueue.hasFollowup(sessionID) })
|
||||
return "third"
|
||||
}),
|
||||
Effect.succeed("third-cancelled"),
|
||||
),
|
||||
)
|
||||
|
||||
// Let msg1 finish.
|
||||
firstReleased.resolve()
|
||||
await first
|
||||
await secondStarted.promise
|
||||
|
||||
// msg2 started after msg3 was enqueued, so hasFollowup should be false for
|
||||
// msg2 — everything waiting is older than msg2's activeSince snapshot.
|
||||
expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(false)
|
||||
secondReleased.resolve()
|
||||
|
||||
expect(await second).toBe("second")
|
||||
expect(await third).toBe("third")
|
||||
|
||||
const events = observed.map((item) => `${item.where}=${item.value}`)
|
||||
expect(events).toEqual(["first:start=false", "first:end=true", "second:start=false", "third:start=false"])
|
||||
})
|
||||
|
||||
test("processes queued prompts without aborting the in-flight stream", async () => {
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const injected = Promise.withResolvers<void>()
|
||||
const calls: number[] = []
|
||||
const replies = ["first reply", "second reply", "third reply"]
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
@@ -235,8 +312,8 @@ describe("session prompt queue", () => {
|
||||
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" })
|
||||
? reply({ text: "first reply", ready: ready.resolve })
|
||||
: reply({ text: "second reply", ready: injected.resolve })
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
@@ -288,43 +365,49 @@ describe("session prompt queue", () => {
|
||||
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 one = await first
|
||||
await injected.promise
|
||||
const two = await second
|
||||
const three = await third
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
|
||||
// The in-flight stream must complete; no aborted error on msg1's reply.
|
||||
expect(one.info.role).toBe("assistant")
|
||||
if (one.info.role === "assistant") expect(one.info.error).toBeUndefined()
|
||||
expect(hasText(one, "first reply")).toBe(true)
|
||||
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 prompts = users.flatMap((msg) =>
|
||||
msg.parts.filter((part) => part.type === "text").map((part) => part.text),
|
||||
)
|
||||
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(users).toHaveLength(2)
|
||||
expect(assistants).toHaveLength(2)
|
||||
expect(prompts).toContain("first prompt")
|
||||
expect(prompts).toContain("second prompt")
|
||||
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)
|
||||
|
||||
const firstUser = users.find((msg) => hasText(msg, "first prompt"))
|
||||
const secondUser = users.find((msg) => hasText(msg, "second prompt"))
|
||||
const firstReply = assistants.find((msg) => hasText(msg, "first reply"))
|
||||
const secondReply = assistants.find((msg) => hasText(msg, "second reply"))
|
||||
if (
|
||||
firstUser?.info.role !== "user" ||
|
||||
secondUser?.info.role !== "user" ||
|
||||
firstReply?.info.role !== "assistant" ||
|
||||
secondReply?.info.role !== "assistant"
|
||||
) {
|
||||
throw new Error("missing expected messages")
|
||||
}
|
||||
expect(firstReply.info.parentID).toBe(firstUser.info.id)
|
||||
expect(secondReply.info.parentID).toBe(secondUser.info.id)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
@@ -392,12 +475,14 @@ describe("session prompt queue", () => {
|
||||
parts: [{ type: "text", text: "third prompt" }],
|
||||
})
|
||||
|
||||
// Let msg2/msg3's enqueue capture the current version before cancel bumps it.
|
||||
await Bun.sleep(20)
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
await SessionPrompt.cancel(session.id)
|
||||
await Promise.all([first, second, third])
|
||||
|
||||
// The queued prompts must never reach the LLM once cancel flushes the queue.
|
||||
expect(calls).toHaveLength(1)
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const assistants = msgs.filter((msg) => msg.info.role === "assistant")
|
||||
@@ -414,10 +499,241 @@ describe("session prompt queue", () => {
|
||||
),
|
||||
)
|
||||
expect(ids).toEqual([])
|
||||
expect(KiloSessionPromptQueue.hasFollowup(session.id)).toBe(false)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("new prompt dismisses a pending suggestion", async () => {
|
||||
const shown = Promise.withResolvers<void>()
|
||||
const dismissed = Promise.withResolvers<void>()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "Suggestion unblock regression" })
|
||||
const offShown = Bus.subscribe(Suggestion.Event.Shown, (event) => {
|
||||
if (event.properties.sessionID === session.id) shown.resolve()
|
||||
})
|
||||
const offDismissed = Bus.subscribe(Suggestion.Event.Dismissed, (event) => {
|
||||
if (event.properties.sessionID === session.id) dismissed.resolve()
|
||||
})
|
||||
|
||||
try {
|
||||
const base = Suggestion.show({
|
||||
sessionID: session.id,
|
||||
text: "Run review?",
|
||||
actions: [{ label: "Review", prompt: "/local-review-uncommitted" }],
|
||||
}).catch((err) => {
|
||||
if (err instanceof Suggestion.DismissedError) return "dismissed"
|
||||
throw err
|
||||
})
|
||||
|
||||
await shown.promise
|
||||
await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "replacement prompt" }],
|
||||
noReply: true,
|
||||
})
|
||||
await dismissed.promise
|
||||
|
||||
expect(await base).toBe("dismissed")
|
||||
expect(await Suggestion.list()).toEqual([])
|
||||
} finally {
|
||||
offShown()
|
||||
offDismissed()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("new prompt dismisses a pending question", async () => {
|
||||
const asked = Promise.withResolvers<void>()
|
||||
const rejected = Promise.withResolvers<void>()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "Question unblock regression" })
|
||||
const offAsked = Bus.subscribe(Question.Event.Asked, (event) => {
|
||||
if (event.properties.sessionID === session.id) asked.resolve()
|
||||
})
|
||||
const offRejected = Bus.subscribe(Question.Event.Rejected, (event) => {
|
||||
if (event.properties.sessionID === session.id) rejected.resolve()
|
||||
})
|
||||
|
||||
try {
|
||||
const pending = Question.ask({
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Continue?",
|
||||
question: "Should I continue?",
|
||||
options: [
|
||||
{ label: "Yes", description: "Go ahead" },
|
||||
{ label: "No", description: "Stop" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}).catch((err) => {
|
||||
if (err instanceof Question.RejectedError) return "rejected"
|
||||
throw err
|
||||
})
|
||||
|
||||
await asked.promise
|
||||
await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "replacement prompt" }],
|
||||
noReply: true,
|
||||
})
|
||||
await rejected.promise
|
||||
|
||||
expect(await pending).toBe("rejected")
|
||||
expect(await Question.list()).toEqual([])
|
||||
} finally {
|
||||
offAsked()
|
||||
offRejected()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("auto-dismisses a suggestion shown after a queued prompt", async () => {
|
||||
// Reverse ordering of the "new prompt dismisses a pending suggestion" test:
|
||||
// queue the follow-up first, then open the blocker. Suggestion.show must see
|
||||
// hasFollowup=true and reject synchronously, before any pending entry or
|
||||
// Shown event is published.
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("ses_auto_suggestion")
|
||||
const started = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
|
||||
// Slot 1: active, activeSince snapshots latest=1.
|
||||
const first = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
MessageID.make("message_auto_sug_1"),
|
||||
Effect.gen(function* () {
|
||||
started.resolve()
|
||||
yield* Effect.promise(() => release.promise)
|
||||
return "first" as const
|
||||
}),
|
||||
Effect.succeed("first-cancelled" as const),
|
||||
),
|
||||
)
|
||||
await started.promise
|
||||
|
||||
// Slot 2: enqueued while slot 1 is active → latest=2 > activeSince=1.
|
||||
const second = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
MessageID.make("message_auto_sug_2"),
|
||||
Effect.succeed("second" as const),
|
||||
Effect.succeed("second-cancelled" as const),
|
||||
),
|
||||
)
|
||||
await Bun.sleep(10)
|
||||
expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true)
|
||||
|
||||
let shown = 0
|
||||
const offShown = Bus.subscribe(Suggestion.Event.Shown, (event) => {
|
||||
if (event.properties.sessionID === sessionID) shown++
|
||||
})
|
||||
try {
|
||||
await expect(
|
||||
Suggestion.show({
|
||||
sessionID,
|
||||
text: "Run review?",
|
||||
actions: [{ label: "Review", prompt: "/local-review-uncommitted" }],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(Suggestion.DismissedError)
|
||||
} finally {
|
||||
offShown()
|
||||
}
|
||||
expect(shown).toBe(0)
|
||||
expect(await Suggestion.list()).toEqual([])
|
||||
|
||||
release.resolve()
|
||||
expect(await first).toBe("first")
|
||||
expect(await second).toBe("second")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("auto-dismisses a question shown after a queued prompt", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("ses_auto_question")
|
||||
const started = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
|
||||
const first = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
MessageID.make("message_auto_q_1"),
|
||||
Effect.gen(function* () {
|
||||
started.resolve()
|
||||
yield* Effect.promise(() => release.promise)
|
||||
return "first" as const
|
||||
}),
|
||||
Effect.succeed("first-cancelled" as const),
|
||||
),
|
||||
)
|
||||
await started.promise
|
||||
|
||||
const second = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
MessageID.make("message_auto_q_2"),
|
||||
Effect.succeed("second" as const),
|
||||
Effect.succeed("second-cancelled" as const),
|
||||
),
|
||||
)
|
||||
await Bun.sleep(10)
|
||||
expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true)
|
||||
|
||||
let asked = 0
|
||||
const offAsked = Bus.subscribe(Question.Event.Asked, (event) => {
|
||||
if (event.properties.sessionID === sessionID) asked++
|
||||
})
|
||||
try {
|
||||
await expect(
|
||||
Question.ask({
|
||||
sessionID,
|
||||
questions: [
|
||||
{
|
||||
header: "Continue?",
|
||||
question: "Should I continue?",
|
||||
options: [
|
||||
{ label: "Yes", description: "Go ahead" },
|
||||
{ label: "No", description: "Stop" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(Question.RejectedError)
|
||||
} finally {
|
||||
offAsked()
|
||||
}
|
||||
expect(asked).toBe(0)
|
||||
expect(await Question.list()).toEqual([])
|
||||
|
||||
release.resolve()
|
||||
expect(await first).toBe("first")
|
||||
expect(await second).toBe("second")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Regression test for the Slot wrapper in plugin/slots.tsx.
|
||||
*
|
||||
* This test locks in two things:
|
||||
* 1. A static invariant: the wrapper does NOT spread raw props (`...props`),
|
||||
* which would silently reintroduce the regression on refactors.
|
||||
* 2. A runtime check: forwarding props through the same pattern used in
|
||||
* slots.tsx preserves reactivity for arbitrary props (not just children).
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { children, createEffect, createRoot, createSignal, mergeProps } from "solid-js"
|
||||
|
||||
const SLOTS_FILE = path.resolve(import.meta.dir, "../../src/cli/cmd/tui/plugin/slots.tsx")
|
||||
|
||||
describe("Slot wrapper preserves prop reactivity", () => {
|
||||
test("slots.tsx does not use `{...props}` spread to forward props", () => {
|
||||
// Spread on a plain object in Solid evaluates every prop once and freezes
|
||||
// it. mergeProps (or a getter per prop) is required to keep reactivity.
|
||||
const content = fs.readFileSync(SLOTS_FILE, "utf-8")
|
||||
const wrapper = content.match(/export const Slot[\s\S]*?^}/m)?.[0] ?? ""
|
||||
expect(wrapper).not.toBe("")
|
||||
expect(wrapper).not.toMatch(/\.\.\.props/)
|
||||
})
|
||||
|
||||
test("slots.tsx forwards props through mergeProps (or per-prop getters)", () => {
|
||||
const content = fs.readFileSync(SLOTS_FILE, "utf-8")
|
||||
const wrapper = content.match(/export const Slot[\s\S]*?^}/m)?.[0] ?? ""
|
||||
const usesMergeProps = /mergeProps\s*\(/.test(wrapper)
|
||||
expect(usesMergeProps).toBe(true)
|
||||
})
|
||||
|
||||
test("mergeProps preserves reactivity of non-children props", () => {
|
||||
// Simulates the exact pattern used in slots.tsx: resolve children via the
|
||||
// `children()` helper and forward the rest via mergeProps. Non-children
|
||||
// reactive props (like `visible`, `disabled`, `ref`) must keep tracking
|
||||
// their source signals — otherwise the slot-internal consumer (opentui
|
||||
// registry → plugin) sees a frozen initial value.
|
||||
const [visible, setVisible] = createSignal(true)
|
||||
const [disabled, setDisabled] = createSignal(false)
|
||||
const refCalls: Array<string> = []
|
||||
const refA = () => refCalls.push("a")
|
||||
const refB = () => refCalls.push("b")
|
||||
const [ref, setRef] = createSignal<() => void>(refA)
|
||||
|
||||
const seen: Array<{ visible: boolean; disabled: boolean }> = []
|
||||
const refSeen: Array<() => void> = []
|
||||
|
||||
const dispose = createRoot((dispose) => {
|
||||
// Pretend JSX: reactive props passed into the Slot wrapper.
|
||||
const sourceProps = {
|
||||
get visible() {
|
||||
return visible()
|
||||
},
|
||||
get disabled() {
|
||||
return disabled()
|
||||
},
|
||||
get ref() {
|
||||
return ref()
|
||||
},
|
||||
children: "unused",
|
||||
}
|
||||
|
||||
// This mirrors plugin/slots.tsx exactly.
|
||||
const value = children(() => sourceProps.children)
|
||||
const merged = mergeProps(sourceProps, {
|
||||
get children() {
|
||||
return value()
|
||||
},
|
||||
}) as typeof sourceProps
|
||||
|
||||
createEffect(() => {
|
||||
seen.push({ visible: merged.visible, disabled: merged.disabled })
|
||||
})
|
||||
createEffect(() => {
|
||||
refSeen.push(merged.ref)
|
||||
})
|
||||
|
||||
return dispose
|
||||
})
|
||||
|
||||
// Initial render tracked.
|
||||
expect(seen).toEqual([{ visible: true, disabled: false }])
|
||||
expect(refSeen.length).toBe(1)
|
||||
expect(refSeen[0]).toBe(refA)
|
||||
|
||||
// Flip the source signals — the merged view must update.
|
||||
setVisible(false)
|
||||
expect(seen).toEqual([
|
||||
{ visible: true, disabled: false },
|
||||
{ visible: false, disabled: false },
|
||||
])
|
||||
|
||||
setDisabled(true)
|
||||
expect(seen[seen.length - 1]).toEqual({ visible: false, disabled: true })
|
||||
|
||||
// Ref callback must also track through the wrapper — this is what makes
|
||||
// the session prompt ref={bind} actually attach/re-attach correctly.
|
||||
setRef(() => refB)
|
||||
expect(refSeen.length).toBe(2)
|
||||
expect(refSeen[1]).toBe(refB)
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
test("plain `{...props}` spread does NOT preserve reactivity (proves the regression)", () => {
|
||||
// Negative control: the exact bug we're guarding against. A spread into a
|
||||
// plain object decouples the reactive source, so an effect on the copy
|
||||
// only fires once.
|
||||
const [visible, setVisible] = createSignal(true)
|
||||
let fires = 0
|
||||
|
||||
const dispose = createRoot((dispose) => {
|
||||
const sourceProps = {
|
||||
get visible() {
|
||||
return visible()
|
||||
},
|
||||
}
|
||||
|
||||
// BUG pattern — copies the value at evaluation time.
|
||||
const frozen = { ...sourceProps } as { visible: boolean }
|
||||
|
||||
createEffect(() => {
|
||||
// Touch frozen.visible to subscribe (but it's a static property now).
|
||||
void frozen.visible
|
||||
fires++
|
||||
})
|
||||
|
||||
return dispose
|
||||
})
|
||||
|
||||
expect(fires).toBe(1)
|
||||
setVisible(false)
|
||||
// A correctly reactive wrapper would have fired again; the frozen copy
|
||||
// does not. Keeping this assertion documents why mergeProps is required.
|
||||
expect(fires).toBe(1)
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { KiloSessionPromptQueue } from "../../../src/kilocode/session/prompt-queue"
|
||||
import { Suggestion } from "../../../src/kilocode/suggestion"
|
||||
import { Instance } from "../../../src/project/instance"
|
||||
import { MessageID, SessionID } from "../../../src/session/schema"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
|
||||
describe("Suggestion.show auto-dismiss on queued followup", () => {
|
||||
test("show rejects immediately when a followup is queued on the session", async () => {
|
||||
// A tool that calls Suggestion.show after a queued prompt has arrived would
|
||||
// otherwise block the turn on user input. Verify the pre-emptive
|
||||
// hasFollowup check rejects with DismissedError before any pending entry
|
||||
// is registered or a Shown event is published.
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const sessionID = SessionID.make("ses_auto_show")
|
||||
const started = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
|
||||
// Slot 1 stays running so activeSince is pinned to its seq.
|
||||
const first = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
MessageID.make("message_show_1"),
|
||||
Effect.gen(function* () {
|
||||
started.resolve()
|
||||
yield* Effect.promise(() => release.promise)
|
||||
return "first" as const
|
||||
}),
|
||||
Effect.succeed("first-cancelled" as const),
|
||||
),
|
||||
)
|
||||
await started.promise
|
||||
|
||||
// Slot 2 arrives while slot 1 is active — latest > activeSince.
|
||||
const second = Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
sessionID,
|
||||
MessageID.make("message_show_2"),
|
||||
Effect.succeed("second" as const),
|
||||
Effect.succeed("second-cancelled" as const),
|
||||
),
|
||||
)
|
||||
await Bun.sleep(10)
|
||||
expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true)
|
||||
|
||||
await expect(
|
||||
Suggestion.show({
|
||||
sessionID,
|
||||
text: "Run review?",
|
||||
actions: [{ label: "Review", prompt: "/local-review-uncommitted" }],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(Suggestion.DismissedError)
|
||||
expect(await Suggestion.list()).toEqual([])
|
||||
|
||||
release.resolve()
|
||||
expect(await first).toBe("first")
|
||||
expect(await second).toBe("second")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,390 @@
|
||||
// Integration tests verifying that the agent file tools (read, write, edit,
|
||||
// apply_patch) detect and preserve the original encoding of files on disk.
|
||||
// Tests exercise the real tool pipeline rather than the Encoding helper
|
||||
// directly so we validate end-to-end behaviour.
|
||||
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import iconv from "iconv-lite"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { ApplyPatchTool } from "../../src/tool/apply_patch"
|
||||
import { Bus } from "../../src/bus"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { EditTool } from "../../src/tool/edit"
|
||||
import { Format } from "../../src/format"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { ReadTool } from "../../src/tool/read"
|
||||
import * as Tool from "../../src/tool/tool"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { WriteTool } from "../../src/tool/write"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test-encoding"),
|
||||
messageID: MessageID.make(""),
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Instruction.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
Bus.layer,
|
||||
Format.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
const runRead = (args: Tool.InferParameters<typeof ReadTool>) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* ReadTool
|
||||
const tool = yield* info.init()
|
||||
return yield* tool.execute(args, ctx)
|
||||
})
|
||||
|
||||
const runWrite = (args: Tool.InferParameters<typeof WriteTool>) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* WriteTool
|
||||
const tool = yield* info.init()
|
||||
return yield* tool.execute(args, ctx)
|
||||
})
|
||||
|
||||
const runEdit = (args: Tool.InferParameters<typeof EditTool>) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* EditTool
|
||||
const tool = yield* info.init()
|
||||
return yield* tool.execute(args, ctx)
|
||||
})
|
||||
|
||||
const runPatch = (args: Tool.InferParameters<typeof ApplyPatchTool>) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* ApplyPatchTool
|
||||
const tool = yield* info.init()
|
||||
return yield* tool.execute(args, ctx)
|
||||
})
|
||||
|
||||
// FileTime was removed upstream; edit/write no longer require a prior read.
|
||||
const markRead = (_filepath: string) => Effect.void
|
||||
|
||||
// iconv-lite's UTF codecs don't emit BOMs, but this codebase supports
|
||||
// "UTF-X with BOM" as a distinct variant. Prepend one here for fixture files
|
||||
// that are meant to have one.
|
||||
const UTF8_BOM = "utf-8-bom"
|
||||
const encodeBytes = (text: string, encoding: string): Buffer => {
|
||||
if (encoding === UTF8_BOM) return Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), iconv.encode(text, "utf-8")])
|
||||
const lower = encoding.toLowerCase()
|
||||
if (lower === "utf-16le") return Buffer.concat([Buffer.from([0xff, 0xfe]), iconv.encode(text, encoding)])
|
||||
if (lower === "utf-16be") return Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode(text, encoding)])
|
||||
return iconv.encode(text, encoding)
|
||||
}
|
||||
|
||||
// Create a file with the given encoding by writing raw bytes.
|
||||
const putEncoded = (filepath: string, text: string, encoding: string) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.mkdir(path.dirname(filepath), { recursive: true })
|
||||
await fs.writeFile(filepath, encodeBytes(text, encoding))
|
||||
})
|
||||
|
||||
const loadDecoded = (filepath: string, encoding: string) =>
|
||||
Effect.promise(async () => {
|
||||
const bytes = await fs.readFile(filepath)
|
||||
if (encoding === UTF8_BOM) {
|
||||
const stripped = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
|
||||
return iconv.decode(stripped ? bytes.subarray(3) : bytes, "utf-8")
|
||||
}
|
||||
return iconv.decode(bytes, encoding)
|
||||
})
|
||||
|
||||
const loadBytes = (filepath: string) => Effect.promise(() => fs.readFile(filepath))
|
||||
|
||||
// Sample phrases chosen to exercise each encoding's characteristic byte patterns.
|
||||
const samples = {
|
||||
utf8: "Hello, world! — £100",
|
||||
shiftJis: "こんにちは、世界!日本語のテストです。",
|
||||
eucJp: "日本語のEUC-JPテスト文字列です。",
|
||||
gb2312: "你好,世界!这是简体中文测试。",
|
||||
big5: "你好,世界!這是繁體中文測試。",
|
||||
eucKr: "안녕하세요, 세계! 한국어 테스트입니다.",
|
||||
windows1251: "Привет, мир! Это тест кириллицы.",
|
||||
koi8r: "Привет, мир! КОИ-8 Р тест.",
|
||||
}
|
||||
|
||||
describe("tool encoding preservation", () => {
|
||||
describe("ReadTool decodes files with non-UTF-8 encodings", () => {
|
||||
const cases: Array<[string, string, string]> = [
|
||||
["UTF-8", "utf-8", samples.utf8],
|
||||
["UTF-8 with BOM", UTF8_BOM, samples.utf8],
|
||||
["UTF-16 LE with BOM", "utf-16le", samples.utf8],
|
||||
["UTF-16 BE with BOM", "utf-16be", samples.utf8],
|
||||
["Shift_JIS", "Shift_JIS", samples.shiftJis],
|
||||
["EUC-JP", "euc-jp", samples.eucJp],
|
||||
["GB2312", "gb2312", samples.gb2312],
|
||||
["Big5", "big5", samples.big5],
|
||||
["EUC-KR", "euc-kr", samples.eucKr],
|
||||
["Windows-1251", "windows-1251", samples.windows1251],
|
||||
["KOI8-R", "koi8-r", samples.koi8r],
|
||||
]
|
||||
|
||||
for (const [label, encoding, text] of cases) {
|
||||
it.live(`decodes ${label} content for the model`, () =>
|
||||
provideEncoded(encoding, text, (filepath) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* runRead({ filePath: filepath })
|
||||
expect(result.output).toContain(text)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe("ReadTool does not flag non-Latin text files as binary", () => {
|
||||
it.live("accepts Shift_JIS", () =>
|
||||
provideEncoded("Shift_JIS", samples.shiftJis, (filepath) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* runRead({ filePath: filepath })
|
||||
expect(result.output).toContain(samples.shiftJis)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("accepts UTF-16 LE with BOM (contains NUL bytes)", () =>
|
||||
provideEncoded("utf-16le", samples.utf8, (filepath) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* runRead({ filePath: filepath })
|
||||
expect(result.output).toContain(samples.utf8)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
describe("WriteTool preserves existing file encoding when overwriting", () => {
|
||||
const cases: Array<[string, string, string]> = [
|
||||
["UTF-8 with BOM", UTF8_BOM, samples.utf8],
|
||||
["Shift_JIS", "Shift_JIS", samples.shiftJis],
|
||||
["GB2312", "gb2312", samples.gb2312],
|
||||
["Windows-1251", "windows-1251", samples.windows1251],
|
||||
["UTF-16 LE", "utf-16le", samples.utf8],
|
||||
]
|
||||
|
||||
for (const [label, encoding, original] of cases) {
|
||||
it.live(`preserves ${label} encoding on overwrite`, () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "file.txt")
|
||||
yield* putEncoded(filepath, original, encoding)
|
||||
yield* markRead(filepath)
|
||||
|
||||
const replacement = original + " updated"
|
||||
yield* runWrite({ filePath: filepath, content: replacement })
|
||||
|
||||
const decoded = yield* loadDecoded(filepath, encoding)
|
||||
expect(decoded).toBe(replacement)
|
||||
|
||||
// Bytes should still match the original encoding (and differ from UTF-8).
|
||||
const bytes = yield* loadBytes(filepath)
|
||||
expect(bytes.equals(encodeBytes(replacement, encoding))).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("defaults new files to UTF-8", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "new.txt")
|
||||
yield* runWrite({ filePath: filepath, content: samples.utf8 })
|
||||
|
||||
const bytes = yield* loadBytes(filepath)
|
||||
expect(bytes.equals(Buffer.from(samples.utf8, "utf-8"))).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Guard against double-BOM regressions: if the model ever hands back content
|
||||
// that already starts with U+FEFF (e.g. by round-tripping literal bytes),
|
||||
// writing it to a BOM-encoded file must still produce exactly one BOM.
|
||||
const bomCases: Array<[string, string, Buffer]> = [
|
||||
["UTF-8 with BOM", UTF8_BOM, Buffer.from([0xef, 0xbb, 0xbf])],
|
||||
["UTF-16 LE", "utf-16le", Buffer.from([0xff, 0xfe])],
|
||||
["UTF-16 BE", "utf-16be", Buffer.from([0xfe, 0xff])],
|
||||
]
|
||||
for (const [label, encoding, bom] of bomCases) {
|
||||
it.live(`does not emit a double BOM for ${label} when content starts with U+FEFF`, () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "file.txt")
|
||||
yield* putEncoded(filepath, "hello", encoding)
|
||||
yield* markRead(filepath)
|
||||
|
||||
yield* runWrite({ filePath: filepath, content: "\uFEFFgoodbye" })
|
||||
|
||||
const bytes = yield* loadBytes(filepath)
|
||||
// Exactly one BOM prefix, immediately followed by encoded payload.
|
||||
expect(bytes.subarray(0, bom.length).equals(bom)).toBe(true)
|
||||
expect(bytes.subarray(bom.length, bom.length * 2).equals(bom)).toBe(false)
|
||||
const decoded = yield* loadDecoded(filepath, encoding)
|
||||
expect(decoded).toBe("goodbye")
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe("EditTool preserves existing file encoding across edits", () => {
|
||||
const cases: Array<[string, string, string, string, string]> = [
|
||||
["UTF-8 with BOM", UTF8_BOM, samples.utf8 + "\n second line", "world", "earth"],
|
||||
["Shift_JIS", "Shift_JIS", samples.shiftJis, "日本語", "ニホンゴ"],
|
||||
["GB2312", "gb2312", samples.gb2312, "简体中文", "中文简体"],
|
||||
["Windows-1251", "windows-1251", samples.windows1251, "мир", "планета"],
|
||||
["UTF-16 LE", "utf-16le", samples.utf8 + "\n second line", "world", "earth"],
|
||||
]
|
||||
|
||||
for (const [label, encoding, original, oldString, newString] of cases) {
|
||||
it.live(`preserves ${label} through edit`, () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "doc.txt")
|
||||
yield* putEncoded(filepath, original, encoding)
|
||||
yield* markRead(filepath)
|
||||
|
||||
yield* runEdit({ filePath: filepath, oldString, newString })
|
||||
|
||||
const decoded = yield* loadDecoded(filepath, encoding)
|
||||
const expected = original.replace(oldString, newString)
|
||||
expect(decoded).toBe(expected)
|
||||
|
||||
const bytes = yield* loadBytes(filepath)
|
||||
expect(bytes.equals(encodeBytes(expected, encoding))).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe("ApplyPatchTool preserves encoding", () => {
|
||||
it.live("preserves Shift_JIS through an update hunk", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "doc.txt")
|
||||
const replacement = "日本語"
|
||||
const original = "line1\n" + samples.shiftJis + "\nline3\n"
|
||||
const expected = original.replace(samples.shiftJis, replacement)
|
||||
yield* putEncoded(filepath, original, "Shift_JIS")
|
||||
|
||||
const patch = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: doc.txt",
|
||||
"@@",
|
||||
" line1",
|
||||
"-" + samples.shiftJis,
|
||||
"+" + replacement,
|
||||
" line3",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
|
||||
yield* runPatch({ patchText: patch })
|
||||
|
||||
const decoded = yield* loadDecoded(filepath, "Shift_JIS")
|
||||
expect(decoded).toBe(expected)
|
||||
|
||||
// Bytes must still be Shift_JIS, not silently promoted to UTF-8.
|
||||
const bytes = yield* loadBytes(filepath)
|
||||
expect(bytes.equals(encodeBytes(expected, "Shift_JIS"))).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("new files added via apply_patch are UTF-8", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const patch = ["*** Begin Patch", "*** Add File: new.txt", "+hello world", "*** End Patch"].join("\n")
|
||||
yield* runPatch({ patchText: patch })
|
||||
const bytes = yield* loadBytes(path.join(dir, "new.txt"))
|
||||
expect(bytes.equals(Buffer.from("hello world\n", "utf-8"))).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Deletes exercise a code path in patch/index.ts that doesn't write bytes
|
||||
// back — verify it still works when the target file is non-UTF-8, because
|
||||
// the deletion code has to decode the old contents to confirm match.
|
||||
it.live("deletes a Windows-1251 file without UTF-8 corruption errors", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "legacy.txt")
|
||||
yield* putEncoded(filepath, samples.windows1251, "windows-1251")
|
||||
const patch = ["*** Begin Patch", "*** Delete File: legacy.txt", "*** End Patch"].join("\n")
|
||||
yield* runPatch({ patchText: patch })
|
||||
const exists = yield* Effect.promise(() =>
|
||||
fs
|
||||
.access(filepath)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
)
|
||||
expect(exists).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
// EditTool's replaceAll path rewrites the entire buffer and re-encodes it
|
||||
// in one shot — regression guard that re-encoding a multi-occurrence edit in
|
||||
// a legacy encoding yields byte-exact output.
|
||||
describe("EditTool replaceAll preserves non-UTF-8 encoding", () => {
|
||||
it.live("replaces every occurrence in Shift_JIS", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "doc.txt")
|
||||
// Pad with additional Shift_JIS text so jschardet has enough bytes
|
||||
// to confidently identify the encoding.
|
||||
const pad = samples.shiftJis + "\n"
|
||||
const original = pad + "日本語\n日本語\n日本語\n" + pad
|
||||
yield* putEncoded(filepath, original, "Shift_JIS")
|
||||
yield* markRead(filepath)
|
||||
|
||||
yield* runEdit({ filePath: filepath, oldString: "日本語", newString: "ニホンゴ", replaceAll: true })
|
||||
|
||||
const expected =
|
||||
pad.replaceAll("日本語", "ニホンゴ") +
|
||||
"ニホンゴ\nニホンゴ\nニホンゴ\n" +
|
||||
pad.replaceAll("日本語", "ニホンゴ")
|
||||
const decoded = yield* loadDecoded(filepath, "Shift_JIS")
|
||||
expect(decoded).toBe(expected)
|
||||
const bytes = yield* loadBytes(filepath)
|
||||
expect(bytes.equals(encodeBytes(expected, "Shift_JIS"))).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Shared helper to set up a temp instance with an encoded file at `file.txt`.
|
||||
function provideEncoded<A, E, R>(encoding: string, text: string, body: (filepath: string) => Effect.Effect<A, E, R>) {
|
||||
return provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "file.txt")
|
||||
yield* putEncoded(filepath, text, encoding)
|
||||
yield* markRead(filepath)
|
||||
return yield* body(filepath)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/plugin",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/sdk",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"name": "@opencode-ai/shared",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"version": "7.2.20",
|
||||
"version": "7.2.22",
|
||||
"dependencies": {},
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user