Merge branch 'main' into imanolmaiztegui/kilo-opencode-v1.14.17

This commit is contained in:
Imanol Maiztegui
2026-04-24 13:31:41 +02:00
103 changed files with 6221 additions and 3084 deletions
@@ -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.
-5
View File
@@ -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.
-5
View File
@@ -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.
-5
View File
@@ -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.
-5
View File
@@ -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.
-7
View File
@@ -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.
+20 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "7.2.20",
"version": "7.2.22",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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",
@@ -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()
@@ -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)
}
@@ -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 ------
@@ -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>
@@ -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`() {
@@ -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(
@@ -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))
}
@@ -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
}
}
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-ui",
"version": "7.2.20",
"version": "7.2.22",
"type": "module",
"license": "MIT",
"exports": {
+28
View File
@@ -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
+1 -1
View File
@@ -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",
+3 -4
View File
@@ -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))
+7 -42
View File
@@ -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
+28
View File
@@ -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
+3 -1
View File
@@ -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 />
+14
View File
@@ -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
}
+142
View File
@@ -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)
}),
)
}
+8 -2
View File
@@ -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 },
})
}
+13 -17
View File
@@ -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,
+1
View File
@@ -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
+18 -1
View File
@@ -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 "."
+22 -5
View File
@@ -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
+21 -15
View File
@@ -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" })
+14 -5
View File
@@ -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,
+14 -5
View File
@@ -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"}"`)
+10 -3
View File
@@ -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
+7 -2
View File
@@ -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 -1
View File
@@ -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": {
+1 -1
View File
@@ -12,6 +12,6 @@
"exports": {
".": "./src/index.ts"
},
"version": "7.2.20",
"version": "7.2.22",
"peerDependencies": {}
}
+1 -1
View File
@@ -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 -1
View File
@@ -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",
+1 -1
View File
@@ -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