Merge remote-tracking branch 'origin/main' into fix/custom-provider-variant-validation

This commit is contained in:
kiloconnect[bot]
2026-04-21 08:52:45 +00:00
103 changed files with 5865 additions and 1646 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-gateway": minor
---
Add Alibaba as a supported AI SDK provider for routing Qwen model requests via the native `@ai-sdk/alibaba` adapter.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Add Alibaba to `kiloProviderOptions` so thinking is enabled correctly when routing through the Kilo gateway with `ai_sdk_provider: "alibaba"`.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Make the `description` parameter of the bash tool optional.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Translate the follow-up prompt shown after finishing a plan. The "Ready to implement?" question, "Start new session" and "Continue here" buttons, and their descriptions now respect the language configured in the VS Code sidebar.
+1
View File
@@ -201,6 +201,7 @@
"name": "@kilocode/kilo-gateway",
"version": "7.2.14",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
+1
View File
@@ -29,6 +29,7 @@
"dependencies": {
"@kilocode/plugin": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
+1 -1
View File
@@ -72,4 +72,4 @@ export const ENV_FEATURE = "KILOCODE_FEATURE"
export const PROMPTS = ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] as const
export const AI_SDK_PROVIDERS = ["anthropic", "openai", "openai-compatible", "openrouter"] as const
export const AI_SDK_PROVIDERS = ["alibaba", "anthropic", "openai", "openai-compatible", "openrouter"] as const
+5
View File
@@ -1,4 +1,5 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { createAlibaba } from "@ai-sdk/alibaba"
import { createAnthropic } from "@ai-sdk/anthropic"
import { createOpenAI } from "@ai-sdk/openai"
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
@@ -76,6 +77,7 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider {
}
const openrouter = createOpenRouter(sdkOptions)
const alibaba = createAlibaba(sdkOptions)
const anthropic = createAnthropic(sdkOptions)
const openai = createOpenAI(sdkOptions)
const openaiCompatible = createOpenAICompatible({ ...sdkOptions, name: "openaiCompatible" })
@@ -93,6 +95,9 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider {
imageModel(modelId) {
return openrouter.imageModel(modelId)
},
alibaba(modelId) {
return alibaba(modelId)
},
anthropic(modelId) {
return anthropic(modelId)
},
+1
View File
@@ -160,6 +160,7 @@ export interface ProviderInfo {
}
export type KiloProvider = Provider & {
alibaba(modelId: string): LanguageModel
anthropic(modelId: string): LanguageModel
openai(modelId: string): LanguageModel
openaiCompatible(modelId: string): LanguageModel
+8
View File
@@ -71,4 +71,12 @@ export const dict = {
"marketplace.warning.cancel": "إلغاء",
"marketplace.contribute.prompt": "هل تفتقد مهارة أو وضعًا أو خادم MCP؟",
"marketplace.contribute.cta": "ساهم على GitHub",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "نفّذ",
"plan.followup.question": "جاهز للتنفيذ؟",
"plan.followup.answer.newSession": "بدء جلسة جديدة",
"plan.followup.answer.newSession.description": "نفّذ في جلسة جديدة بسياق نظيف",
"plan.followup.answer.continue": "المتابعة هنا",
"plan.followup.answer.continue.description": "نفّذ الخطة في هذه الجلسة",
}
+8
View File
@@ -71,4 +71,12 @@ export const dict = {
"marketplace.warning.cancel": "Cancelar",
"marketplace.contribute.prompt": "Está faltando uma skill, modo ou servidor MCP?",
"marketplace.contribute.cta": "Contribuir no GitHub",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "Implementar",
"plan.followup.question": "Pronto para implementar?",
"plan.followup.answer.newSession": "Iniciar nova sessão",
"plan.followup.answer.newSession.description": "Implementar em uma nova sessão com contexto limpo",
"plan.followup.answer.continue": "Continuar aqui",
"plan.followup.answer.continue.description": "Implementar o plano nesta sessão",
}
+8
View File
@@ -76,4 +76,12 @@ export const dict = {
"marketplace.warning.cancel": "Otkaži",
"marketplace.contribute.prompt": "Nedostaje vještina, način rada ili MCP server?",
"marketplace.contribute.cta": "Doprinesi na GitHub-u",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "Implementiraj",
"plan.followup.question": "Spreman za implementaciju?",
"plan.followup.answer.newSession": "Pokreni novu sesiju",
"plan.followup.answer.newSession.description": "Implementiraj u novoj sesiji s čistim kontekstom",
"plan.followup.answer.continue": "Nastavi ovdje",
"plan.followup.answer.continue.description": "Implementiraj plan u ovoj sesiji",
}
+8
View File
@@ -71,4 +71,12 @@ export const dict = {
"marketplace.warning.cancel": "Annuller",
"marketplace.contribute.prompt": "Mangler du en skill, tilstand eller MCP-server?",
"marketplace.contribute.cta": "Bidrag på GitHub",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "Implementér",
"plan.followup.question": "Klar til at implementere?",
"plan.followup.answer.newSession": "Start ny session",
"plan.followup.answer.newSession.description": "Implementér i en ny session med ren kontekst",
"plan.followup.answer.continue": "Fortsæt her",
"plan.followup.answer.continue.description": "Implementér planen i denne session",
}
+8
View File
@@ -71,4 +71,12 @@ export const dict = {
"marketplace.warning.cancel": "Abbrechen",
"marketplace.contribute.prompt": "Fehlt ein Skill, Modus oder MCP-Server?",
"marketplace.contribute.cta": "Auf GitHub beitragen",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "Umsetzen",
"plan.followup.question": "Bereit zur Umsetzung?",
"plan.followup.answer.newSession": "Neue Sitzung starten",
"plan.followup.answer.newSession.description": "In einer neuen Sitzung mit leerem Kontext umsetzen",
"plan.followup.answer.continue": "Hier fortfahren",
"plan.followup.answer.continue.description": "Den Plan in dieser Sitzung umsetzen",
}
+10
View File
@@ -73,4 +73,14 @@ export const dict = {
"marketplace.warning.cancel": "Cancel",
"marketplace.contribute.prompt": "Missing a skill, mode, or MCP server?",
"marketplace.contribute.cta": "Contribute on GitHub",
// Plan follow-up question shown after plan_exit. The English strings here must match
// the canonical `label`/`header`/`question` sent by the backend — those canonical labels
// are still what the backend matches on (see packages/opencode/src/kilocode/plan-followup.ts).
"plan.followup.header": "Implement",
"plan.followup.question": "Ready to implement?",
"plan.followup.answer.newSession": "Start new session",
"plan.followup.answer.newSession.description": "Implement in a fresh session with a clean context",
"plan.followup.answer.continue": "Continue here",
"plan.followup.answer.continue.description": "Implement the plan in this session",
}
+8
View File
@@ -71,4 +71,12 @@ export const dict = {
"marketplace.warning.cancel": "Cancelar",
"marketplace.contribute.prompt": "¿Falta una skill, modo o servidor MCP?",
"marketplace.contribute.cta": "Contribuir en GitHub",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "Implementar",
"plan.followup.question": "¿Listo para implementar?",
"plan.followup.answer.newSession": "Iniciar nueva sesión",
"plan.followup.answer.newSession.description": "Implementar en una sesión nueva con contexto limpio",
"plan.followup.answer.continue": "Continuar aquí",
"plan.followup.answer.continue.description": "Implementar el plan en esta sesión",
}
+8
View File
@@ -71,4 +71,12 @@ export const dict = {
"marketplace.warning.cancel": "Annuler",
"marketplace.contribute.prompt": "Il manque une skill, un mode ou un serveur MCP ?",
"marketplace.contribute.cta": "Contribuer sur GitHub",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "Implémenter",
"plan.followup.question": "Prêt à implémenter ?",
"plan.followup.answer.newSession": "Démarrer une nouvelle session",
"plan.followup.answer.newSession.description": "Implémenter dans une nouvelle session avec un contexte vierge",
"plan.followup.answer.continue": "Continuer ici",
"plan.followup.answer.continue.description": "Implémenter le plan dans cette session",
}
+8
View File
@@ -69,4 +69,12 @@ export const dict = {
"marketplace.warning.cancel": "キャンセル",
"marketplace.contribute.prompt": "スキル、モード、MCP サーバーが見つかりませんか?",
"marketplace.contribute.cta": "GitHub で貢献する",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "実装",
"plan.followup.question": "実装する準備はできましたか?",
"plan.followup.answer.newSession": "新しいセッションを開始",
"plan.followup.answer.newSession.description": "クリーンなコンテキストの新しいセッションで実装する",
"plan.followup.answer.continue": "ここで続行",
"plan.followup.answer.continue.description": "このセッションで計画を実装する",
}
+8
View File
@@ -69,4 +69,12 @@ export const dict = {
"marketplace.warning.cancel": "취소",
"marketplace.contribute.prompt": "스킬, 모드 또는 MCP 서버가 없나요?",
"marketplace.contribute.cta": "GitHub에서 기여하기",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "구현",
"plan.followup.question": "구현할 준비가 되셨나요?",
"plan.followup.answer.newSession": "새 세션 시작",
"plan.followup.answer.newSession.description": "깨끗한 컨텍스트의 새 세션에서 구현",
"plan.followup.answer.continue": "여기서 계속하기",
"plan.followup.answer.continue.description": "이 세션에서 계획 구현",
}
+8
View File
@@ -73,4 +73,12 @@ export const dict = {
"marketplace.warning.cancel": "Annuleren",
"marketplace.contribute.prompt": "Mist er een skill, modus of MCP-server?",
"marketplace.contribute.cta": "Bijdragen op GitHub",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "Implementeren",
"plan.followup.question": "Klaar om te implementeren?",
"plan.followup.answer.newSession": "Nieuwe sessie starten",
"plan.followup.answer.newSession.description": "Implementeren in een nieuwe sessie met een lege context",
"plan.followup.answer.continue": "Hier doorgaan",
"plan.followup.answer.continue.description": "Het plan in deze sessie implementeren",
}
+8
View File
@@ -71,4 +71,12 @@ export const dict = {
"marketplace.warning.cancel": "Avbryt",
"marketplace.contribute.prompt": "Mangler du en skill, modus eller MCP-server?",
"marketplace.contribute.cta": "Bidra på GitHub",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "Implementer",
"plan.followup.question": "Klar til å implementere?",
"plan.followup.answer.newSession": "Start ny økt",
"plan.followup.answer.newSession.description": "Implementer i en ny økt med ren kontekst",
"plan.followup.answer.continue": "Fortsett her",
"plan.followup.answer.continue.description": "Implementer planen i denne økten",
}
+8
View File
@@ -71,4 +71,12 @@ export const dict = {
"marketplace.warning.cancel": "Anuluj",
"marketplace.contribute.prompt": "Brakuje skilla, trybu lub serwera MCP?",
"marketplace.contribute.cta": "Wnieś wkład na GitHubie",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "Wdróż",
"plan.followup.question": "Gotowy do wdrożenia?",
"plan.followup.answer.newSession": "Rozpocznij nową sesję",
"plan.followup.answer.newSession.description": "Wdróż w nowej sesji z czystym kontekstem",
"plan.followup.answer.continue": "Kontynuuj tutaj",
"plan.followup.answer.continue.description": "Wdróż plan w tej sesji",
}
+8
View File
@@ -71,4 +71,12 @@ export const dict = {
"marketplace.warning.cancel": "Отмена",
"marketplace.contribute.prompt": "Не хватает навыка, режима или MCP-сервера?",
"marketplace.contribute.cta": "Внести вклад на GitHub",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "Реализовать",
"plan.followup.question": "Готовы реализовать?",
"plan.followup.answer.newSession": "Начать новую сессию",
"plan.followup.answer.newSession.description": "Реализовать в новой сессии с чистым контекстом",
"plan.followup.answer.continue": "Продолжить здесь",
"plan.followup.answer.continue.description": "Реализовать план в этой сессии",
}
+8
View File
@@ -71,4 +71,12 @@ export const dict = {
"marketplace.warning.cancel": "ยกเลิก",
"marketplace.contribute.prompt": "ไม่พบทักษะ โหมด หรือเซิร์ฟเวอร์ MCP ที่ต้องการ?",
"marketplace.contribute.cta": "ร่วมสมทบบน GitHub",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "ดำเนินการ",
"plan.followup.question": "พร้อมดำเนินการหรือยัง?",
"plan.followup.answer.newSession": "เริ่มเซสชันใหม่",
"plan.followup.answer.newSession.description": "ดำเนินการในเซสชันใหม่ที่มีบริบทว่างเปล่า",
"plan.followup.answer.continue": "ดำเนินการต่อที่นี่",
"plan.followup.answer.continue.description": "ดำเนินการตามแผนในเซสชันนี้",
}
+8
View File
@@ -71,4 +71,12 @@ export const dict = {
"marketplace.warning.cancel": "İptal",
"marketplace.contribute.prompt": "Bir yetenek, mod veya MCP sunucusu mu eksik?",
"marketplace.contribute.cta": "GitHub'da katkıda bulun",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "Uygula",
"plan.followup.question": "Uygulamaya hazır mısın?",
"plan.followup.answer.newSession": "Yeni oturum başlat",
"plan.followup.answer.newSession.description": "Temiz bir bağlamla yeni bir oturumda uygula",
"plan.followup.answer.continue": "Burada devam et",
"plan.followup.answer.continue.description": "Planı bu oturumda uygula",
}
+8
View File
@@ -71,4 +71,12 @@ export const dict = {
"marketplace.warning.cancel": "Скасувати",
"marketplace.contribute.prompt": "Бракує навички, режиму або MCP-сервера?",
"marketplace.contribute.cta": "Зробити внесок на GitHub",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "Реалізувати",
"plan.followup.question": "Готові реалізувати?",
"plan.followup.answer.newSession": "Почати нову сесію",
"plan.followup.answer.newSession.description": "Реалізувати в новій сесії з чистим контекстом",
"plan.followup.answer.continue": "Продовжити тут",
"plan.followup.answer.continue.description": "Реалізувати план у цій сесії",
}
+8
View File
@@ -68,4 +68,12 @@ export const dict = {
"marketplace.warning.cancel": "取消",
"marketplace.contribute.prompt": "缺少技能、模式或 MCP 服务器?",
"marketplace.contribute.cta": "在 GitHub 上贡献",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "实现",
"plan.followup.question": "准备好实现了吗?",
"plan.followup.answer.newSession": "开启新会话",
"plan.followup.answer.newSession.description": "在具有干净上下文的新会话中实现",
"plan.followup.answer.continue": "在此继续",
"plan.followup.answer.continue.description": "在本会话中实现计划",
}
+8
View File
@@ -68,4 +68,12 @@ export const dict = {
"marketplace.warning.cancel": "取消",
"marketplace.contribute.prompt": "缺少技能、模式或 MCP 伺服器?",
"marketplace.contribute.cta": "在 GitHub 上貢獻",
// Plan follow-up question shown after plan_exit
"plan.followup.header": "實作",
"plan.followup.question": "準備好實作了嗎?",
"plan.followup.answer.newSession": "開啟新工作階段",
"plan.followup.answer.newSession.description": "在具有乾淨上下文的新工作階段中實作",
"plan.followup.answer.continue": "在此繼續",
"plan.followup.answer.continue.description": "在本工作階段中實作計畫",
}
+129
View File
@@ -74,6 +74,135 @@
- Service classes ↔ `<applicationService>`/`<projectService>` entries in the corresponding module XML
- `script/build.ts` platform list ↔ `backend/build.gradle.kts` `requiredPlatforms` list
## Session Component
The chat session feature uses a three-layer Model / Controller / View architecture. All files live under
`frontend/src/main/kotlin/ai/kilocode/client/session/`.
### Layers
**`SessionModel`** (`model/SessionModel.kt`)
- Single source of truth for session content and runtime state.
- **EDT-only access** — no synchronisation. `SessionController` guarantees all reads and writes happen on the EDT.
- State is mutated only through dedicated methods (`setState`, `upsertMessage`, `setDiff`, etc.), never via direct field assignment from outside the model.
- Every mutation fires a sealed `SessionModelEvent` that carries the data needed for rendering — UI never needs to read back from the model after receiving an event.
- Each event overrides `toString()` with a compact, stable label (e.g. `"MessageAdded msg1"`, `"DiffUpdated files=2"`). Tests assert events by comparing joined `toString()` output.
- `loadHistory()` and `clear()` reset all state fields — diff, todos, compactionCount, messages, and `SessionState.Idle`. Call them when opening or clearing a session.
**`SessionController`** (`SessionController.kt`)
- Owns one `SessionModel`. UIs read from `model` and subscribe to `SessionModelEvent` via `model.addListener()`.
- Accepts an optional `id` at construction.
- `id = null` → lazily creates a new session on the first `prompt()` call. This guarantees events are subscribed before the prompt is sent, eliminating race conditions.
- `id != null` → immediately loads history and subscribes to SSE events on construction.
- After history load, `recoverPending()` seeds state in this priority order: (1) pending permission, (2) pending question, (3) current session status (`busy`/`retry`/`offline` from `KiloSessionService.statuses`), (4) `Idle`.
- All SSE events are filtered by `sessionID` before being handled. `session.error` events with `null` sessionID are treated as global and pass through.
- Publishes coarser lifecycle updates (app/workspace changes, view switching) via `SessionControllerEvent` to registered listeners — keep these separate from the fine-grained `SessionModelEvent` stream.
**View** (UI classes under `ui/`)
- Listens to `SessionModelEvent` via `model.addListener(parent) { event -> when(event) { ... } }`.
- The `when` block must be exhaustive — add `-> Unit` branches for events the view intentionally ignores so new events surface as compile errors.
- Views call `SessionController` actions (`prompt()`, `replyPermission()`, etc.) on the EDT; the controller dispatches RPC calls to a coroutine scope.
- Views must never access RPC or services directly — everything goes through the controller.
### Adding a New Event
1. Add a subclass to `SessionModelEvent` with a stable `toString()`.
2. Add the corresponding state field and mutation method to `SessionModel`. Reset the field in both `loadHistory()` and `clear()`.
3. Handle the new `ChatEventDto` in `SessionController.handle()` by calling the model mutation method.
4. Add `-> Unit` stubs for the new event in any existing exhaustive `when` blocks in view code.
### Testing
Controller tests extend `SessionControllerTestBase` (`test/…/session/SessionControllerTestBase.kt`),
which provides a real IntelliJ Application and EDT via `BasePlatformTestCase`, real frontend services
wired to `FakeSessionRpcApi`, and a set of shared helpers.
**Two setups:**
| Setup | When to use |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `val (m, events, modelEvents) = prompted()` | New-session flow — sets app/workspace to ready, creates a controller with no ID, sends an initial prompt. `model.showMessages` is `true`. Start all event-driven tests from here. |
| `controller("ses_test")` + manual `appRpc`/`projectRpc` setup + `flush()` | Existing-session flow — opens a specific session, triggers history load and `recoverPending()`. `model.showMessages` is `false`. Use for recovery and history tests. Pass `show = false` to `assertSession`. |
**Core assertion helpers:**
```kotlin
// Full controller state — includes model transcript + status line.
// show=true is the default; pass show=false for existing-session tests.
assertSession("""
assistant#msg1
text#prt1:
hello
[code] [kilo/gpt-5] [idle]
""", m)
// Just the model transcript (no status line)
assertModel("diff: src/A.kt src/B.kt", m)
// Model event stream — one event per line via event.toString()
assertModelEvents("""
MessageAdded msg1
ContentAdded msg1/prt1
""", modelEvents)
// Controller lifecycle events
assertControllerEvents("WorkspaceReady", events)
```
**Emitting events and flushing:**
```kotlin
emit(ChatEventDto.TurnOpen("ses_test")) // emits + flushes by default
emit(ChatEventDto.PartDelta(…), flush = false) // batch without intermediate flush
flush() // settle coroutines + drain EDT
```
**`FakeSessionRpcApi` configurable state:**
| Field | Purpose |
| ------------------------------------------------------------------ | ------------------------------------------------------ |
| `rpc.events` (`MutableSharedFlow`) | Emit `ChatEventDto` events the controller will receive |
| `rpc.statuses` (`MutableStateFlow<Map<String, SessionStatusDto>>`) | Seed the status map read during `recoverPending()` |
| `rpc.history` | Messages returned by `messages()` (history load) |
| `rpc.pendingPermissionList` | Permissions returned during recovery |
| `rpc.pendingQuestionList` | Questions returned during recovery |
| `rpc.prompts`, `rpc.permissionReplies`, etc. | Call tracking for RPC side-effects |
**String format of `model.toString()`** (used by `assertModel` / `assertSession`):
```
role#msgId
text#partId:
line one
line two
---
tool#partId toolName [STATE] optional title
---
question#id
tool: msgId/callId
header: …
prompt: …
option: label - description
multiple: false
custom: true
---
diff: file1 file2
---
todo: [status] content
---
compacted: N
```
Sections are separated by `---`. Only non-empty sections appear. The status line appended by `SessionController.toString()` is:
```
[agentName] [provider/modelId] [idle|busy|retry|offline|error|awaiting-question|awaiting-permission] [optional detail]
```
## UI Design Guidelines
Official references:
@@ -5,7 +5,12 @@ import ai.kilocode.backend.util.KiloLog
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -38,9 +43,20 @@ class KiloBackendChatManager(
"message.removed",
"message.part.updated",
"message.part.delta",
"message.part.removed",
"session.turn.open",
"session.turn.close",
"session.error",
"session.status",
"session.idle",
"session.compacted",
"session.diff",
"permission.asked",
"permission.replied",
"question.asked",
"question.replied",
"question.rejected",
"todo.updated",
)
}
@@ -174,8 +190,70 @@ class KiloBackendChatManager(
}
}
// ------ permission / question ------
fun replyPermission(requestId: String, dir: String, reply: PermissionReplyDto) {
val body = KiloCliDataParser.buildPermissionReplyJson(reply)
post("/permission/$requestId/reply?directory=${encode(dir)}", body, "replyPermission")
}
fun savePermissionRules(requestId: String, dir: String, rules: PermissionAlwaysRulesDto) {
val body = KiloCliDataParser.buildPermissionAlwaysRulesJson(rules)
post("/permission/$requestId/always-rules?directory=${encode(dir)}", body, "savePermissionRules")
}
fun replyQuestion(requestId: String, dir: String, answers: QuestionReplyDto) {
val body = KiloCliDataParser.buildQuestionReplyJson(answers)
post("/question/$requestId/reply?directory=${encode(dir)}", body, "replyQuestion")
}
fun rejectQuestion(requestId: String, dir: String) {
post("/question/$requestId/reject?directory=${encode(dir)}", "{}", "rejectQuestion")
}
fun pendingPermissions(dir: String): List<PermissionRequestDto> {
val raw = get("/permission?directory=${encode(dir)}", "pendingPermissions") ?: return emptyList()
return KiloCliDataParser.parsePermissionRequests(raw)
}
fun pendingQuestions(dir: String): List<QuestionRequestDto> {
val raw = get("/question?directory=${encode(dir)}", "pendingQuestions") ?: return emptyList()
return KiloCliDataParser.parseQuestionRequests(raw)
}
// ------ utilities ------
private fun post(path: String, body: String, op: String) {
val http = requireClient()
val url = requireBase()
val request = Request.Builder()
.url("$url$path")
.post(body.toRequestBody(JSON_TYPE))
.build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
log.warn("$op failed: HTTP ${response.code}")
}
}
}
private fun get(path: String, op: String): String? {
val http = requireClient()
val url = requireBase()
val request = Request.Builder()
.url("$url$path")
.get()
.build()
return http.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
log.warn("$op failed: HTTP ${response.code}")
null
} else {
response.body?.string()
}
}
}
private fun requireClient(): OkHttpClient =
client ?: throw IllegalStateException("Chat manager not started")
@@ -2,19 +2,30 @@ package ai.kilocode.backend.cli
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageErrorDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionInfoDto
import ai.kilocode.rpc.dto.QuestionOptionDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionStatusDto
import ai.kilocode.rpc.dto.SessionSummaryDto
import ai.kilocode.rpc.dto.SessionTimeDto
import ai.kilocode.rpc.dto.TodoDto
import ai.kilocode.rpc.dto.TokensDto
import ai.kilocode.rpc.dto.ToolRefDto
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
@@ -112,6 +123,88 @@ object KiloCliDataParser {
ChatEventDto.Error(sid, err)
}
"permission.asked" -> {
val sid = props.str("sessionID") ?: return null
val request = parsePermissionRequest(props) ?: return null
ChatEventDto.PermissionAsked(sid, request)
}
"permission.replied" -> {
val sid = props.str("sessionID") ?: return null
val rid = props.str("requestID") ?: return null
ChatEventDto.PermissionReplied(sid, rid)
}
"question.asked" -> {
val sid = props.str("sessionID") ?: return null
val request = parseQuestionRequest(props) ?: return null
ChatEventDto.QuestionAsked(sid, request)
}
"question.replied" -> {
val sid = props.str("sessionID") ?: return null
val rid = props.str("requestID") ?: return null
ChatEventDto.QuestionReplied(sid, rid)
}
"question.rejected" -> {
val sid = props.str("sessionID") ?: return null
val rid = props.str("requestID") ?: return null
ChatEventDto.QuestionRejected(sid, rid)
}
"message.part.removed" -> {
val sid = props.str("sessionID") ?: return null
val mid = props.str("messageID") ?: return null
val pid = props.str("partID") ?: return null
ChatEventDto.PartRemoved(sid, mid, pid)
}
"session.status" -> {
val sid = props.str("sessionID") ?: return null
val st = props["status"]?.jsonObject ?: return null
val dto = parseStatus(st)
ChatEventDto.SessionStatusChanged(sid, dto)
}
"session.idle" -> {
val sid = props.str("sessionID") ?: return null
ChatEventDto.SessionIdle(sid)
}
"session.compacted" -> {
val sid = props.str("sessionID") ?: return null
ChatEventDto.SessionCompacted(sid)
}
"session.diff" -> {
val sid = props.str("sessionID") ?: return null
val diffs = props["diff"]?.jsonArray?.mapNotNull { elem ->
val d = elem.jsonObject
val file = d.str("file") ?: return@mapNotNull null
DiffFileDto(
file = file,
additions = d.long("additions")?.toInt() ?: 0,
deletions = d.long("deletions")?.toInt() ?: 0,
patch = d.str("patch"),
)
} ?: emptyList()
ChatEventDto.SessionDiffChanged(sid, diffs)
}
"todo.updated" -> {
val sid = props.str("sessionID") ?: return null
val todos = props["todos"]?.jsonArray?.map { elem ->
val t = elem.jsonObject
TodoDto(
content = t.str("content") ?: "",
status = t.str("status") ?: "pending",
priority = t.str("priority") ?: "medium",
)
} ?: emptyList()
ChatEventDto.TodoUpdated(sid, todos)
}
else -> null
}
}
@@ -119,15 +212,14 @@ object KiloCliDataParser {
/**
* Parse an SSE `session.status` event into a (sessionID, [SessionStatusDto]) pair.
* Returns null if the required fields are missing.
*
* Uses regex extraction (no full JSON parse) for consistency with
* the existing high-throughput status event handling.
*/
fun parseSessionStatus(data: String): Pair<String, SessionStatusDto>? {
val id = extractField(data, "sessionID") ?: return null
val type = extractNested(data, "status", "type") ?: "idle"
val msg = extractNested(data, "status", "message")
return id to SessionStatusDto(type, msg)
val obj = tryParseObject(data) ?: return null
val payload = obj["payload"]?.jsonObject ?: obj
val props = payload["properties"]?.jsonObject ?: obj
val id = props.str("sessionID") ?: return null
val st = props["status"]?.jsonObject ?: return id to SessionStatusDto("idle")
return id to parseStatus(st)
}
// ================================================================
@@ -255,6 +347,7 @@ object KiloCliDataParser {
type = obj.str("type") ?: "unknown",
text = obj.str("text"),
tool = obj.str("tool"),
callID = obj.str("callID"),
state = state?.str("status"),
title = state?.str("title"),
)
@@ -268,6 +361,40 @@ object KiloCliDataParser {
return MessageErrorDto(type, msg)
}
internal fun parsePermissionRequest(obj: JsonObject): PermissionRequestDto? {
val id = obj.str("id") ?: return null
val sid = obj.str("sessionID") ?: return null
val permission = obj.str("permission") ?: return null
val patterns = obj["patterns"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
val always = obj["always"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
val meta = obj["metadata"]?.jsonObject?.let { m ->
m.entries.associate { (k, v) -> k to (v.jsonPrimitive.contentOrNull ?: "") }
} ?: emptyMap()
val ref = toolRef(obj)
return PermissionRequestDto(id, sid, permission, patterns, meta, always, ref)
}
internal fun parseQuestionRequest(obj: JsonObject): QuestionRequestDto? {
val id = obj.str("id") ?: return null
val sid = obj.str("sessionID") ?: return null
val questions = obj["questions"]?.jsonArray?.map { q ->
val qo = q.jsonObject
val options = qo["options"]?.jsonArray?.map { o ->
val oo = o.jsonObject
QuestionOptionDto(oo.str("label") ?: "", oo.str("description") ?: "")
} ?: emptyList()
QuestionInfoDto(
question = qo.str("question") ?: "",
header = qo.str("header") ?: "",
options = options,
multiple = qo.str("multiple") == "true",
custom = qo.str("custom") != "false",
)
} ?: emptyList()
val ref = toolRef(obj)
return QuestionRequestDto(id, sid, questions, ref)
}
private fun parseSessionObject(obj: JsonObject): SessionDto {
val time = obj["time"]?.jsonObject
val summary = obj["summary"]?.jsonObject
@@ -293,6 +420,91 @@ object KiloCliDataParser {
)
}
// ================================================================
// Internal — status parsing
// ================================================================
/** Safely parse an optional tool reference, handling `null` JSON values. */
private fun toolRef(obj: JsonObject): ToolRefDto? {
val elem = obj["tool"] ?: return null
if (elem is JsonNull) return null
val t = elem.jsonObject
val mid = t.str("messageID") ?: return null
val cid = t.str("callID") ?: return null
return ToolRefDto(mid, cid)
}
internal fun parseStatus(st: JsonObject): SessionStatusDto {
val type = st.str("type") ?: "idle"
return SessionStatusDto(
type = type,
message = st.str("message"),
attempt = st.long("attempt")?.toInt(),
next = st.long("next"),
requestID = st.str("requestID"),
)
}
// ================================================================
// HTTP response parsing — lists
// ================================================================
/**
* Parse a list of pending permission requests (`GET /permission`).
*/
fun parsePermissionRequests(raw: String): List<PermissionRequestDto> {
val arr = tryParseArray(raw) ?: return emptyList()
return arr.mapNotNull { elem ->
parsePermissionRequest(elem.jsonObject)
}
}
/**
* Parse a list of pending question requests (`GET /question`).
*/
fun parseQuestionRequests(raw: String): List<QuestionRequestDto> {
val arr = tryParseArray(raw) ?: return emptyList()
return arr.mapNotNull { elem ->
parseQuestionRequest(elem.jsonObject)
}
}
// ================================================================
// JSON serialization (DTO → JSON for outgoing permission/question requests)
// ================================================================
/**
* Build the JSON body for `POST /permission/{requestID}/reply`.
*/
fun buildPermissionReplyJson(reply: PermissionReplyDto): String {
val sb = StringBuilder()
sb.append("""{"reply":${escape(reply.reply)}""")
val msg = reply.message
if (msg != null) sb.append(""","message":${escape(msg)}""")
sb.append("}")
return sb.toString()
}
/**
* Build the JSON body for `POST /permission/{requestID}/always-rules`.
*/
fun buildPermissionAlwaysRulesJson(rules: PermissionAlwaysRulesDto): String {
val approved = rules.approvedAlways.joinToString(",") { escape(it) }
val denied = rules.deniedAlways.joinToString(",") { escape(it) }
return """{"approvedAlways":[$approved],"deniedAlways":[$denied]}"""
}
/**
* Build the JSON body for `POST /question/{requestID}/reply`.
*/
fun buildQuestionReplyJson(reply: QuestionReplyDto): String {
val answers = reply.answers.joinToString(",") { inner ->
val items = inner.joinToString(",") { escape(it) }
"[$items]"
}
return """{"answers":[$answers]}"""
}
// ================================================================
// Internal — regex-based field extraction (for status events)
// ================================================================
@@ -10,7 +10,12 @@ import ai.kilocode.rpc.KiloSessionRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionListDto
import ai.kilocode.rpc.dto.SessionStatusDto
@@ -88,14 +93,53 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
is ChatEventDto.MessageUpdated -> event.sessionID
is ChatEventDto.PartUpdated -> event.sessionID
is ChatEventDto.PartDelta -> event.sessionID
is ChatEventDto.PartRemoved -> event.sessionID
is ChatEventDto.TurnOpen -> event.sessionID
is ChatEventDto.TurnClose -> event.sessionID
is ChatEventDto.Error -> event.sessionID
is ChatEventDto.MessageRemoved -> event.sessionID
is ChatEventDto.PermissionAsked -> event.sessionID
is ChatEventDto.PermissionReplied -> event.sessionID
is ChatEventDto.QuestionAsked -> event.sessionID
is ChatEventDto.QuestionReplied -> event.sessionID
is ChatEventDto.QuestionRejected -> event.sessionID
is ChatEventDto.SessionStatusChanged -> event.sessionID
is ChatEventDto.SessionIdle -> event.sessionID
is ChatEventDto.SessionCompacted -> event.sessionID
is ChatEventDto.SessionDiffChanged -> event.sessionID
is ChatEventDto.TodoUpdated -> event.sessionID
}
sid == id
sid == null || sid == id
}
override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) =
chat.updateConfig(directory, config)
// ------ permission / question resolution ------
override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) {
LOG.info("replyPermission: requestId=$requestId, reply=${reply.reply}")
chat.replyPermission(requestId, directory, reply)
}
override suspend fun savePermissionRules(requestId: String, directory: String, rules: PermissionAlwaysRulesDto) {
LOG.info("savePermissionRules: requestId=$requestId")
chat.savePermissionRules(requestId, directory, rules)
}
override suspend fun replyQuestion(requestId: String, directory: String, answers: QuestionReplyDto) {
LOG.info("replyQuestion: requestId=$requestId, answers=${answers.answers.size}")
chat.replyQuestion(requestId, directory, answers)
}
override suspend fun rejectQuestion(requestId: String, directory: String) {
LOG.info("rejectQuestion: requestId=$requestId")
chat.rejectQuestion(requestId, directory)
}
override suspend fun pendingPermissions(directory: String): List<PermissionRequestDto> =
chat.pendingPermissions(directory)
override suspend fun pendingQuestions(directory: String): List<QuestionRequestDto> =
chat.pendingQuestions(directory)
}
@@ -0,0 +1,201 @@
package ai.kilocode.backend.cli
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageErrorDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.QuestionInfoDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionStatusDto
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Round-trip serialization tests for [ChatEventDto].
*
* Verifies that the @SerialName values match the real CLI event type strings
* and that polymorphic serialization/deserialization works end-to-end.
*/
class ChatDtoSerializationTest {
private val json = Json {
ignoreUnknownKeys = true
classDiscriminator = "type"
}
// ================================================================
// Serial name round-trips — confirms @SerialName matches CLI types
// ================================================================
@Test
fun `MessageUpdated serializes with correct type discriminator`() {
val event: ChatEventDto = ChatEventDto.MessageUpdated(
sessionID = "ses_1",
info = msg("msg_1"),
)
val encoded = json.encodeToString(ChatEventDto.serializer(), event)
assertTrue(encoded.contains(""""type":"message.updated""""), "Expected type=message.updated in: $encoded")
val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded)
assertTrue(decoded is ChatEventDto.MessageUpdated)
assertEquals("ses_1", (decoded as ChatEventDto.MessageUpdated).sessionID)
}
@Test
fun `PartUpdated serializes with type message dot part dot updated`() {
val event: ChatEventDto = ChatEventDto.PartUpdated(
sessionID = "ses_1",
part = part("p1"),
)
val encoded = json.encodeToString(ChatEventDto.serializer(), event)
assertTrue(encoded.contains(""""type":"message.part.updated""""), "Expected type=message.part.updated in: $encoded")
val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded)
assertTrue(decoded is ChatEventDto.PartUpdated)
}
@Test
fun `PartDelta serializes with type message dot part dot delta`() {
val event: ChatEventDto = ChatEventDto.PartDelta("ses_1", "msg_1", "part_1", "text", "hello")
val encoded = json.encodeToString(ChatEventDto.serializer(), event)
assertTrue(encoded.contains(""""type":"message.part.delta""""), "Expected type=message.part.delta in: $encoded")
val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.PartDelta
assertEquals("hello", decoded.delta)
}
@Test
fun `PartRemoved serializes with type message dot part dot removed`() {
val event: ChatEventDto = ChatEventDto.PartRemoved("ses_1", "msg_1", "part_1")
val encoded = json.encodeToString(ChatEventDto.serializer(), event)
assertTrue(encoded.contains(""""type":"message.part.removed""""), "Expected type=message.part.removed in: $encoded")
val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.PartRemoved
assertEquals("part_1", decoded.partID)
}
@Test
fun `TurnOpen serializes with type session dot turn dot open`() {
val event: ChatEventDto = ChatEventDto.TurnOpen("ses_1")
val encoded = json.encodeToString(ChatEventDto.serializer(), event)
assertTrue(encoded.contains(""""type":"session.turn.open""""), "Expected type=session.turn.open in: $encoded")
val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded)
assertTrue(decoded is ChatEventDto.TurnOpen)
}
@Test
fun `TurnClose serializes with type session dot turn dot close`() {
val event: ChatEventDto = ChatEventDto.TurnClose("ses_1", "completed")
val encoded = json.encodeToString(ChatEventDto.serializer(), event)
assertTrue(encoded.contains(""""type":"session.turn.close""""), "Expected type=session.turn.close in: $encoded")
val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.TurnClose
assertEquals("completed", decoded.reason)
}
@Test
fun `Error serializes with type session dot error`() {
val event: ChatEventDto = ChatEventDto.Error("ses_1", MessageErrorDto("timeout", "Timed out"))
val encoded = json.encodeToString(ChatEventDto.serializer(), event)
assertTrue(encoded.contains(""""type":"session.error""""), "Expected type=session.error in: $encoded")
}
@Test
fun `SessionStatusChanged serializes with full retry detail`() {
val event: ChatEventDto = ChatEventDto.SessionStatusChanged(
sessionID = "ses_1",
status = SessionStatusDto("retry", "Retrying", attempt = 3, next = 5000L),
)
val encoded = json.encodeToString(ChatEventDto.serializer(), event)
assertTrue(encoded.contains(""""type":"session.status""""), "Expected type=session.status in: $encoded")
assertTrue(encoded.contains(""""attempt":3"""), "Expected attempt in: $encoded")
assertTrue(encoded.contains(""""next":5000"""), "Expected next in: $encoded")
val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.SessionStatusChanged
assertEquals(3, decoded.status.attempt)
assertEquals(5000L, decoded.status.next)
}
@Test
fun `SessionStatusChanged serializes with offline requestID`() {
val event: ChatEventDto = ChatEventDto.SessionStatusChanged(
sessionID = "ses_1",
status = SessionStatusDto("offline", "No connection", requestID = "req_abc"),
)
val encoded = json.encodeToString(ChatEventDto.serializer(), event)
assertTrue(encoded.contains(""""requestID":"req_abc""""), "Expected requestID in: $encoded")
val decoded = json.decodeFromString(ChatEventDto.serializer(), encoded) as ChatEventDto.SessionStatusChanged
assertEquals("req_abc", decoded.status.requestID)
}
@Test
fun `PermissionAsked serializes with correct type`() {
val event: ChatEventDto = ChatEventDto.PermissionAsked(
sessionID = "ses_1",
request = PermissionRequestDto("p1", "ses_1", "edit", listOf("*.kt")),
)
val encoded = json.encodeToString(ChatEventDto.serializer(), event)
assertTrue(encoded.contains(""""type":"permission.asked""""))
}
@Test
fun `QuestionAsked serializes with correct type`() {
val event: ChatEventDto = ChatEventDto.QuestionAsked(
sessionID = "ses_1",
request = QuestionRequestDto("q1", "ses_1", listOf(QuestionInfoDto("pick", "h"))),
)
val encoded = json.encodeToString(ChatEventDto.serializer(), event)
assertTrue(encoded.contains(""""type":"question.asked""""))
}
@Test
fun `TodoUpdated serializes with correct type`() {
val event: ChatEventDto = ChatEventDto.TodoUpdated("ses_1", emptyList())
val encoded = json.encodeToString(ChatEventDto.serializer(), event)
assertTrue(encoded.contains(""""type":"todo.updated""""))
}
@Test
fun `SessionIdle serializes with correct type`() {
val event: ChatEventDto = ChatEventDto.SessionIdle("ses_1")
val encoded = json.encodeToString(ChatEventDto.serializer(), event)
assertTrue(encoded.contains(""""type":"session.idle""""))
}
@Test
fun `SessionCompacted serializes with correct type`() {
val event: ChatEventDto = ChatEventDto.SessionCompacted("ses_1")
val encoded = json.encodeToString(ChatEventDto.serializer(), event)
assertTrue(encoded.contains(""""type":"session.compacted""""))
}
@Test
fun `PartDto callID is preserved in round-trip`() {
val part = PartDto(
id = "p1", sessionID = "s1", messageID = "m1",
type = "tool", tool = "bash", callID = "call_abc",
)
val encoded = json.encodeToString(PartDto.serializer(), part)
assertTrue(encoded.contains(""""callID":"call_abc""""))
val decoded = json.decodeFromString(PartDto.serializer(), encoded)
assertEquals("call_abc", decoded.callID)
}
// ------ helpers ------
private fun msg(id: String) = MessageDto(
id = id, sessionID = "ses_1", role = "assistant",
time = MessageTimeDto(created = 0.0),
)
private fun part(id: String) = PartDto(
id = id, sessionID = "ses_1", messageID = "msg_1", type = "text",
)
}
@@ -3,8 +3,11 @@ package ai.kilocode.backend.cli
import ai.kilocode.backend.cli.KiloCliDataParser
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.PromptPartDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@@ -465,6 +468,380 @@ class KiloCliDataParserTest {
assertTrue(result.contains(""""agent":{"ask":{"temperature":0.5}}"""))
}
// ================================================================
// parseChatEvent — permission / question events
// ================================================================
@Test
fun `parseChatEvent - permission asked`() {
val data = globalEvent("""
"type": "permission.asked",
"properties": {
"id": "perm_1",
"sessionID": "ses_1",
"permission": "edit",
"patterns": ["*.kt"],
"always": [],
"metadata": {"file": "src/A.kt"},
"tool": {"messageID": "msg_1", "callID": "call_1"}
}
""")
val result = KiloCliDataParser.parseChatEvent("permission.asked", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.PermissionAsked)
assertEquals("ses_1", result.sessionID)
assertEquals("perm_1", result.request.id)
assertEquals("edit", result.request.permission)
assertEquals(listOf("*.kt"), result.request.patterns)
assertEquals("src/A.kt", result.request.metadata["file"])
assertEquals("msg_1", result.request.tool?.messageID)
}
@Test
fun `parseChatEvent - permission replied`() {
val data = globalEvent("""
"type": "permission.replied",
"properties": { "sessionID": "ses_1", "requestID": "perm_1" }
""")
val result = KiloCliDataParser.parseChatEvent("permission.replied", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.PermissionReplied)
assertEquals("ses_1", result.sessionID)
assertEquals("perm_1", result.requestID)
}
@Test
fun `parseChatEvent - question asked`() {
val data = globalEvent("""
"type": "question.asked",
"properties": {
"id": "q_1",
"sessionID": "ses_1",
"questions": [{"question": "Pick one", "header": "Choice", "options": [{"label": "A", "description": "Option A"}]}],
"tool": null
}
""")
val result = KiloCliDataParser.parseChatEvent("question.asked", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.QuestionAsked)
assertEquals("ses_1", result.sessionID)
assertEquals("q_1", result.request.id)
assertEquals(1, result.request.questions.size)
assertEquals("Pick one", result.request.questions[0].question)
assertEquals("A", result.request.questions[0].options[0].label)
}
@Test
fun `parseChatEvent - question replied`() {
val data = globalEvent("""
"type": "question.replied",
"properties": { "sessionID": "ses_1", "requestID": "q_1" }
""")
val result = KiloCliDataParser.parseChatEvent("question.replied", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.QuestionReplied)
assertEquals("q_1", result.requestID)
}
@Test
fun `parseChatEvent - question rejected`() {
val data = globalEvent("""
"type": "question.rejected",
"properties": { "sessionID": "ses_1", "requestID": "q_1" }
""")
val result = KiloCliDataParser.parseChatEvent("question.rejected", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.QuestionRejected)
assertEquals("q_1", result.requestID)
}
// ================================================================
// parseChatEvent — session.status with retry/offline detail
// ================================================================
@Test
fun `parseChatEvent - session status idle`() {
val data = globalEvent("""
"type": "session.status",
"properties": { "sessionID": "ses_1", "status": {"type": "idle"} }
""")
val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged
assertEquals("idle", result.status.type)
assertNull(result.status.attempt)
assertNull(result.status.requestID)
}
@Test
fun `parseChatEvent - session status retry with attempt and next`() {
val data = globalEvent("""
"type": "session.status",
"properties": {
"sessionID": "ses_1",
"status": {"type": "retry", "message": "Retrying...", "attempt": 2, "next": 5000}
}
""")
val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged
assertEquals("retry", result.status.type)
assertEquals("Retrying...", result.status.message)
assertEquals(2, result.status.attempt)
assertEquals(5000L, result.status.next)
}
@Test
fun `parseChatEvent - session status offline with requestID`() {
val data = globalEvent("""
"type": "session.status",
"properties": {
"sessionID": "ses_1",
"status": {"type": "offline", "message": "No network", "requestID": "req_abc"}
}
""")
val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged
assertEquals("offline", result.status.type)
assertEquals("No network", result.status.message)
assertEquals("req_abc", result.status.requestID)
}
// ================================================================
// parseChatEvent — message.part.removed
// ================================================================
@Test
fun `parseChatEvent - message part removed`() {
val data = globalEvent("""
"type": "message.part.removed",
"properties": { "sessionID": "ses_1", "messageID": "msg_1", "partID": "part_1" }
""")
val result = KiloCliDataParser.parseChatEvent("message.part.removed", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.PartRemoved)
assertEquals("ses_1", result.sessionID)
assertEquals("msg_1", result.messageID)
assertEquals("part_1", result.partID)
}
// ================================================================
// parseChatEvent — todo.updated
// ================================================================
@Test
fun `parseChatEvent - todo updated`() {
val data = globalEvent("""
"type": "todo.updated",
"properties": {
"sessionID": "ses_1",
"todos": [
{"content": "Write tests", "status": "in_progress", "priority": "high"},
{"content": "Review PR", "status": "pending", "priority": "medium"}
]
}
""")
val result = KiloCliDataParser.parseChatEvent("todo.updated", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.TodoUpdated)
assertEquals("ses_1", result.sessionID)
assertEquals(2, result.todos.size)
assertEquals("Write tests", result.todos[0].content)
assertEquals("high", result.todos[0].priority)
}
// ================================================================
// parseChatEvent — session.idle / session.compacted / session.diff
// ================================================================
@Test
fun `parseChatEvent - session idle`() {
val data = globalEvent("""
"type": "session.idle",
"properties": { "sessionID": "ses_1" }
""")
val result = KiloCliDataParser.parseChatEvent("session.idle", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.SessionIdle)
assertEquals("ses_1", result.sessionID)
}
@Test
fun `parseChatEvent - session compacted`() {
val data = globalEvent("""
"type": "session.compacted",
"properties": { "sessionID": "ses_1" }
""")
val result = KiloCliDataParser.parseChatEvent("session.compacted", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.SessionCompacted)
}
@Test
fun `parseChatEvent - session diff`() {
val data = globalEvent("""
"type": "session.diff",
"properties": {
"sessionID": "ses_1",
"diff": [{"file": "src/A.kt", "additions": 3, "deletions": 1, "patch": "@@ ..."}]
}
""")
val result = KiloCliDataParser.parseChatEvent("session.diff", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.SessionDiffChanged)
assertEquals(1, result.diff.size)
assertEquals("src/A.kt", result.diff[0].file)
assertEquals(3, result.diff[0].additions)
}
// ================================================================
// parseChatEvent — part with callID
// ================================================================
@Test
fun `parseChatEvent - part updated with callID`() {
val data = globalEvent("""
"type": "message.part.updated",
"properties": {
"sessionID": "ses_1",
"part": {
"id": "part_1",
"sessionID": "ses_1",
"messageID": "msg_1",
"type": "tool",
"tool": "bash",
"callID": "call_abc",
"state": { "status": "running" }
}
}
""")
val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) as ChatEventDto.PartUpdated
assertEquals("call_abc", result.part.callID)
assertEquals("bash", result.part.tool)
}
// ================================================================
// parseSessionStatus — full detail
// ================================================================
@Test
fun `parseSessionStatus - retry preserves attempt and next`() {
val data = globalEvent("""
"type": "session.status",
"properties": {
"sessionID": "ses_retry",
"status": {"type": "retry", "message": "Rate limited", "attempt": 3, "next": 10000}
}
""")
val result = KiloCliDataParser.parseSessionStatus(data)
assertNotNull(result)
assertEquals("ses_retry", result.first)
assertEquals("retry", result.second.type)
assertEquals(3, result.second.attempt)
assertEquals(10000L, result.second.next)
}
@Test
fun `parseSessionStatus - offline preserves requestID`() {
val data = globalEvent("""
"type": "session.status",
"properties": {
"sessionID": "ses_off",
"status": {"type": "offline", "message": "Offline", "requestID": "req_xyz"}
}
""")
val result = KiloCliDataParser.parseSessionStatus(data)
assertNotNull(result)
assertEquals("req_xyz", result.second.requestID)
}
// ================================================================
// buildPermissionReplyJson
// ================================================================
@Test
fun `buildPermissionReplyJson - once reply`() {
val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "once"))
assertEquals("""{"reply":"once"}""", result)
}
@Test
fun `buildPermissionReplyJson - always reply with message`() {
val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "always", message = "approved"))
assertTrue(result.contains(""""reply":"always""""))
assertTrue(result.contains(""""message":"approved""""))
}
// ================================================================
// buildPermissionAlwaysRulesJson
// ================================================================
@Test
fun `buildPermissionAlwaysRulesJson - approved list`() {
val result = KiloCliDataParser.buildPermissionAlwaysRulesJson(
PermissionAlwaysRulesDto(approvedAlways = listOf("src/**"), deniedAlways = emptyList())
)
assertTrue(result.contains(""""approvedAlways":["src/**"]"""))
assertTrue(result.contains(""""deniedAlways":[]"""))
}
// ================================================================
// buildQuestionReplyJson
// ================================================================
@Test
fun `buildQuestionReplyJson - single question single answer`() {
val result = KiloCliDataParser.buildQuestionReplyJson(QuestionReplyDto(answers = listOf(listOf("A"))))
assertEquals("""{"answers":[["A"]]}""", result)
}
@Test
fun `buildQuestionReplyJson - multiple questions`() {
val result = KiloCliDataParser.buildQuestionReplyJson(
QuestionReplyDto(answers = listOf(listOf("A", "B"), listOf("Yes")))
)
assertEquals("""{"answers":[["A","B"],["Yes"]]}""", result)
}
// ================================================================
// parsePermissionRequests / parseQuestionRequests
// ================================================================
@Test
fun `parsePermissionRequests - parses list`() {
val raw = """[
{"id": "p1", "sessionID": "s1", "permission": "edit", "patterns": ["*.kt"], "always": [], "metadata": {}}
]"""
val result = KiloCliDataParser.parsePermissionRequests(raw)
assertEquals(1, result.size)
assertEquals("p1", result[0].id)
assertEquals("edit", result[0].permission)
}
@Test
fun `parsePermissionRequests - empty list`() {
assertEquals(emptyList(), KiloCliDataParser.parsePermissionRequests("[]"))
}
@Test
fun `parseQuestionRequests - parses list`() {
val raw = """[
{"id": "q1", "sessionID": "s1", "questions": [{"question": "pick", "header": "h", "options": []}]}
]"""
val result = KiloCliDataParser.parseQuestionRequests(raw)
assertEquals(1, result.size)
assertEquals("q1", result[0].id)
}
// ================================================================
// Helpers
// ================================================================
@@ -19,6 +19,11 @@ dependencies {
implementation(project(":shared"))
implementation(libs.commonmark)
implementation(libs.commonmark.autolink)
implementation(libs.commonmark.tables)
implementation(libs.commonmark.strikethrough)
testImplementation(kotlin("test"))
testImplementation("junit:junit:4.13.2")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.11.4")
@@ -62,10 +62,10 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware {
cs: CoroutineScope,
) {
try {
val chat = SessionUi(project, workspace, sessions, app, cs)
val ui = SessionUi(project, workspace, sessions, app, cs)
val content = ContentFactory.getInstance()
.createContent(chat, "", false)
content.setDisposer(chat)
.createContent(ui, "", false)
content.setDisposer(ui)
toolWindow.contentManager.addContent(content)
ActionManager.getInstance().getAction("Kilo.Settings")?.let {
@@ -6,8 +6,13 @@ import ai.kilocode.rpc.KiloSessionRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.PromptPartDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionStatusDto
import com.intellij.openapi.components.Service
@@ -28,7 +33,7 @@ import kotlinx.coroutines.launch
* Project-level frontend service for session management.
*
* Stateless with respect to "active session" — callers pass explicit
* session IDs. [ai.kilocode.client.session.model.SessionModel] owns the
* session IDs. [ai.kilocode.client.session.SessionController] owns the
* active session concept.
*/
@Service(Service.Level.PROJECT)
@@ -143,4 +148,34 @@ class KiloSessionService internal constructor(
suspend fun updateConfig(dir: String, config: ConfigUpdateDto) {
call { updateConfig(dir, config) }
}
// ------ permission / question resolution ------
/** Reply to a pending permission request. */
suspend fun replyPermission(requestId: String, dir: String, reply: PermissionReplyDto) {
call { replyPermission(requestId, dir, reply) }
}
/** Save always-rules for a pending permission request. */
suspend fun savePermissionRules(requestId: String, dir: String, rules: PermissionAlwaysRulesDto) {
call { savePermissionRules(requestId, dir, rules) }
}
/** Reply to a pending question with user answers. */
suspend fun replyQuestion(requestId: String, dir: String, answers: QuestionReplyDto) {
call { replyQuestion(requestId, dir, answers) }
}
/** Reject a pending question. */
suspend fun rejectQuestion(requestId: String, dir: String) {
call { rejectQuestion(requestId, dir) }
}
/** List pending permissions (caller filters by session ID). */
suspend fun pendingPermissions(dir: String): List<PermissionRequestDto> =
call { pendingPermissions(dir) }
/** List pending questions (caller filters by session ID). */
suspend fun pendingQuestions(dir: String): List<QuestionRequestDto> =
call { pendingQuestions(dir) }
}
@@ -0,0 +1,574 @@
package ai.kilocode.client.session
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.AgentItem
import ai.kilocode.client.session.model.ModelItem
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.session.model.Permission
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.PermissionRequestState
import ai.kilocode.client.session.model.Question
import ai.kilocode.client.session.model.QuestionItem
import ai.kilocode.client.session.model.QuestionOption
import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionStatusDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.util.Disposer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Session lifecycle orchestrator for a single session.
*
* Accepts an optional [id] — if non-null, loads that session immediately.
* If null, lazily creates a session on the first [prompt] call. This ensures
* event subscription happens before the prompt is sent, eliminating races.
*
* Owns [SessionModel] — the single source of truth for session content and
* state. UIs observe model changes via [SessionModelEvent] on [model].
* Lifecycle events (app/workspace state, view switching) are published
* via [SessionControllerEvent] to registered listeners.
*/
class SessionController(
parent: Disposable,
id: String?,
private val sessions: KiloSessionService,
private val workspace: Workspace,
private val app: KiloAppService,
private val cs: CoroutineScope,
) : Disposable {
companion object {
private val LOG = Logger.getInstance(SessionController::class.java)
}
init {
Disposer.register(parent, this)
}
val model = SessionModel()
private val listeners = mutableListOf<SessionControllerListener>()
private var sessionId: String? = id
private val directory: String get() = workspace.directory
private var partType: String? = null
private var tool: String? = null
private var eventJob: Job? = null
val ready: Boolean get() = model.isReady()
fun addListener(parent: Disposable, listener: SessionControllerListener) {
listeners.add(listener)
Disposer.register(parent) { listeners.remove(listener) }
}
fun prompt(text: String) {
showMessages()
cs.launch {
try {
val id = sessionId ?: run {
val session = sessions.create(directory)
sessionId = session.id
subscribeEvents()
session.id
}
sessions.prompt(id, directory, text)
} catch (e: Exception) {
LOG.warn("prompt failed", e)
edt {
val msg = e.message ?: KiloBundle.message("session.error.prompt")
model.setState(SessionState.Error(msg))
}
}
}
}
fun abort() {
val id = sessionId ?: return
cs.launch {
try {
sessions.abort(id, directory)
} catch (e: Exception) {
LOG.warn("abort failed", e)
}
}
}
fun selectAgent(name: String) {
model.agent = name
cs.launch {
try {
sessions.updateConfig(directory, ConfigUpdateDto(agent = name))
} catch (e: Exception) {
LOG.warn("selectAgent failed", e)
}
}
fire(SessionControllerEvent.WorkspaceReady)
}
fun selectModel(provider: String, id: String) {
model.model = "$provider/$id"
cs.launch {
try {
sessions.updateConfig(directory, ConfigUpdateDto(model = "$provider/$id"))
} catch (e: Exception) {
LOG.warn("selectModel failed", e)
}
}
fire(SessionControllerEvent.WorkspaceReady)
}
// ------ permission / question resolution ------
fun replyPermission(requestId: String, reply: PermissionReplyDto, rules: PermissionAlwaysRulesDto? = null) {
cs.launch {
try {
if (rules != null) sessions.savePermissionRules(requestId, directory, rules)
sessions.replyPermission(requestId, directory, reply)
} catch (e: Exception) {
LOG.warn("replyPermission failed", e)
}
}
}
fun replyQuestion(requestId: String, answers: QuestionReplyDto) {
cs.launch {
try {
sessions.replyQuestion(requestId, directory, answers)
} catch (e: Exception) {
LOG.warn("replyQuestion failed", e)
}
}
}
fun rejectQuestion(requestId: String) {
cs.launch {
try {
sessions.rejectQuestion(requestId, directory)
} catch (e: Exception) {
LOG.warn("rejectQuestion failed", e)
}
}
}
init {
if (sessionId != null) {
loadHistory()
subscribeEvents()
}
app.connect()
cs.launch {
app.state.collect { state ->
if (state.status == KiloAppStatusDto.READY) app.fetchVersionAsync()
fire(SessionControllerEvent.AppChanged) {
model.app = state
model.version = app.version
}
}
}
cs.launch {
workspace.state.collect { state ->
fire(SessionControllerEvent.WorkspaceChanged) {
model.workspace = state
if (state.status != KiloWorkspaceStatusDto.READY) return@fire
model.agents = state.agents?.agents?.map {
AgentItem(it.name, it.displayName ?: it.name)
} ?: emptyList()
model.models = state.providers?.let { providers ->
providers.providers
.filter { it.id in providers.connected }
.flatMap { provider ->
provider.models.map { (id, info) ->
ModelItem(id, info.name, provider.id)
}
}
} ?: emptyList()
if (this@SessionController.model.agent == null) this@SessionController.model.agent = state.agents?.default
if (this@SessionController.model.model == null) {
this@SessionController.model.model = state.providers?.defaults?.entries?.firstOrNull()?.let { "${it.key}/${it.value}" }
}
}
if (state.status == KiloWorkspaceStatusDto.READY) {
fire(SessionControllerEvent.WorkspaceReady)
}
}
}
}
private fun loadHistory() {
val id = sessionId ?: return
cs.launch {
try {
val history = sessions.messages(id, directory)
edt {
this@SessionController.model.loadHistory(history)
if (!model.isEmpty()) showMessages()
}
recoverPending(id)
} catch (e: Exception) {
LOG.warn("loadHistory failed", e)
}
}
}
private fun subscribeEvents() {
val id = sessionId ?: return
eventJob?.cancel()
eventJob = cs.launch {
sessions.events(id, directory).collect { event ->
// Defensive session ID guard: skip events for other sessions
if (!matchesSession(event, id)) return@collect
edt { handle(event) }
}
}
}
/** Rehydrate pending permissions/questions and current session status after history load. */
private suspend fun recoverPending(id: String) {
try {
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id }
val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id }
val status = sessions.statuses.value[id]
edt {
if (permissions.isNotEmpty()) {
model.setState(SessionState.AwaitingPermission(toPermission(permissions.last())))
} else if (questions.isNotEmpty()) {
model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last())))
} else if (status != null) {
seedStatus(status)
}
}
} catch (e: Exception) {
LOG.warn("recoverPending failed", e)
}
}
/**
* Seed initial session state from a snapshot status value.
*
* Used only during recovery — does not apply the live-event clobbering guard
* for "busy" because no more-specific state has arrived yet.
*/
private fun seedStatus(dto: SessionStatusDto) {
val state = when (dto.type) {
"busy" -> SessionState.Busy(KiloBundle.message("session.status.considering"))
"retry" -> SessionState.Retry(
message = dto.message ?: "",
attempt = dto.attempt ?: 0,
next = dto.next ?: 0L,
)
"offline" -> SessionState.Offline(
message = dto.message ?: "",
requestId = dto.requestID ?: "",
)
else -> return // idle or unknown — leave as Idle
}
model.setState(state)
}
private fun handle(event: ChatEventDto) {
when (event) {
is ChatEventDto.MessageUpdated -> {
val added = model.upsertMessage(event.info)
if (added) showMessages()
}
is ChatEventDto.PartUpdated -> {
partType = event.part.type
tool = event.part.tool
model.updateContent(event.part.messageID, event.part)
if (model.state is SessionState.Busy) {
model.setState(SessionState.Busy(status()))
}
}
is ChatEventDto.PartDelta -> {
if (event.field == "text") {
model.appendDelta(event.messageID, event.partID, event.delta)
}
}
is ChatEventDto.PartRemoved -> {
model.removeContent(event.messageID, event.partID)
}
is ChatEventDto.TurnOpen -> {
partType = null
tool = null
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
}
is ChatEventDto.TurnClose -> {
partType = null
tool = null
// "completed" always transitions to idle.
// Other reasons: don't clobber a more specific terminal state (Error,
// AwaitingPermission, AwaitingQuestion) that arrived just before close.
val current = model.state
val clobberOk = event.reason == "completed"
|| current is SessionState.Busy
|| current is SessionState.Retry
|| current is SessionState.Offline
if (clobberOk) model.setState(SessionState.Idle)
}
is ChatEventDto.Error -> {
partType = null
tool = null
val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown")
model.setState(SessionState.Error(msg, event.error?.type))
}
is ChatEventDto.MessageRemoved -> {
model.removeMessage(event.messageID)
}
is ChatEventDto.PermissionAsked -> {
model.setState(SessionState.AwaitingPermission(toPermission(event.request)))
}
is ChatEventDto.PermissionReplied -> {
val current = model.state
if (current is SessionState.AwaitingPermission && current.permission.id == event.requestID) {
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
}
}
is ChatEventDto.QuestionAsked -> {
model.setState(SessionState.AwaitingQuestion(toQuestion(event.request)))
}
is ChatEventDto.QuestionReplied -> {
val current = model.state
if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) {
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
}
}
is ChatEventDto.QuestionRejected -> {
val current = model.state
if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) {
model.setState(SessionState.Idle)
}
}
is ChatEventDto.SessionStatusChanged -> {
val state = when (event.status.type) {
"idle" -> SessionState.Idle
"busy" -> {
val current = model.state
if (current is SessionState.Idle || current is SessionState.Error)
SessionState.Busy(KiloBundle.message("session.status.considering"))
else return // already in a more specific phase
}
"retry" -> SessionState.Retry(
message = event.status.message ?: "",
attempt = event.status.attempt ?: 0,
next = event.status.next ?: 0L,
)
"offline" -> SessionState.Offline(
message = event.status.message ?: "",
requestId = event.status.requestID ?: "",
)
else -> return
}
model.setState(state)
}
is ChatEventDto.SessionIdle -> {
// Treat session.idle as an explicit signal to return to Idle.
// Only apply if we're not in a more specific non-terminal state.
val current = model.state
if (current !is SessionState.Error
&& current !is SessionState.AwaitingPermission
&& current !is SessionState.AwaitingQuestion
) {
model.setState(SessionState.Idle)
}
}
is ChatEventDto.SessionCompacted -> model.markCompacted()
is ChatEventDto.SessionDiffChanged -> model.setDiff(event.diff)
is ChatEventDto.TodoUpdated -> model.setTodos(event.todos)
}
}
private fun showMessages() {
if (!model.showMessages) {
model.showMessages = true
fire(SessionControllerEvent.ViewChanged(true))
}
}
private fun status(): String = when (partType) {
"reasoning" -> KiloBundle.message("session.status.thinking")
"text" -> KiloBundle.message("session.status.writing")
"tool" -> when (tool) {
"task" -> KiloBundle.message("session.status.delegating")
"todowrite", "todoread" -> KiloBundle.message("session.status.planning")
"read" -> KiloBundle.message("session.status.gathering")
"glob", "grep", "list" -> KiloBundle.message("session.status.searching.codebase")
"webfetch", "websearch", "codesearch" -> KiloBundle.message("session.status.searching.web")
"edit", "write" -> KiloBundle.message("session.status.editing")
"bash" -> KiloBundle.message("session.status.commands")
else -> KiloBundle.message("session.status.considering")
}
else -> KiloBundle.message("session.status.considering")
}
private fun fire(event: SessionControllerEvent, before: (() -> Unit)? = null) {
val application = ApplicationManager.getApplication()
if (application.isDispatchThread) {
before?.invoke()
for (l in listeners) l.onEvent(event)
return
}
application.invokeLater {
before?.invoke()
for (l in listeners) l.onEvent(event)
}
}
private fun edt(block: () -> Unit) {
ApplicationManager.getApplication().invokeLater(block)
}
override fun dispose() {
eventJob?.cancel()
cs.cancel()
}
override fun toString(): String {
val out = mutableListOf<String>()
val body = model.toString().trim()
if (body.isNotEmpty()) out.add(body)
if (out.isNotEmpty()) out.add("")
out.add(statusLine())
return out.joinToString("\n")
}
private fun statusLine(): String {
val out = mutableListOf<String>()
model.agent?.takeIf { it.isNotBlank() }?.let { out.add("[$it]") }
model.model?.takeIf { it.isNotBlank() }?.let { out.add("[$it]") }
if (!ready) {
out.add("[app: ${model.app.status}]")
out.add("[workspace: ${model.workspace.status}]")
return out.joinToString(" ")
}
when (val state = model.state) {
is SessionState.Idle -> out.add("[idle]")
is SessionState.Busy -> {
out.add("[busy]")
out.add("[${state.text.toDumpText()}]")
}
is SessionState.AwaitingQuestion -> out.add("[awaiting-question]")
is SessionState.AwaitingPermission -> out.add("[awaiting-permission]")
is SessionState.Retry -> {
out.add("[retry]")
state.message.takeIf { it.isNotBlank() }?.let { out.add("[$it]") }
}
is SessionState.Offline -> {
out.add("[offline]")
state.message.takeIf { it.isNotBlank() }?.let { out.add("[$it]") }
}
is SessionState.Error -> {
out.add("[error]")
out.add("[${state.message}]")
}
}
return out.joinToString(" ")
}
}
/** Returns true when [event]'s sessionID matches [id] (or event has no sessionID, like Error). */
private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (event) {
is ChatEventDto.MessageUpdated -> event.sessionID == id
is ChatEventDto.PartUpdated -> event.sessionID == id
is ChatEventDto.PartDelta -> event.sessionID == id
is ChatEventDto.PartRemoved -> event.sessionID == id
is ChatEventDto.TurnOpen -> event.sessionID == id
is ChatEventDto.TurnClose -> event.sessionID == id
is ChatEventDto.Error -> event.sessionID == null || event.sessionID == id
is ChatEventDto.MessageRemoved -> event.sessionID == id
is ChatEventDto.PermissionAsked -> event.sessionID == id
is ChatEventDto.PermissionReplied -> event.sessionID == id
is ChatEventDto.QuestionAsked -> event.sessionID == id
is ChatEventDto.QuestionReplied -> event.sessionID == id
is ChatEventDto.QuestionRejected -> event.sessionID == id
is ChatEventDto.SessionStatusChanged -> event.sessionID == id
is ChatEventDto.SessionIdle -> event.sessionID == id
is ChatEventDto.SessionCompacted -> event.sessionID == id
is ChatEventDto.SessionDiffChanged -> event.sessionID == id
is ChatEventDto.TodoUpdated -> event.sessionID == id
}
private fun toPermission(dto: PermissionRequestDto): Permission {
val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) }
val file = dto.metadata["file"] ?: dto.metadata["path"]
val state = dto.metadata["state"]?.let { raw ->
PermissionRequestState.values().firstOrNull { item -> item.name.equals(raw, ignoreCase = true) }
} ?: PermissionRequestState.PENDING
return Permission(
id = dto.id,
sessionId = dto.sessionID,
name = dto.permission,
patterns = dto.patterns,
always = dto.always,
meta = PermissionMeta(filePath = file, raw = dto.metadata),
tool = ref,
state = state,
)
}
private fun toQuestion(dto: QuestionRequestDto): Question {
val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) }
val items = dto.questions.map {
QuestionItem(
question = it.question,
header = it.header,
options = it.options.map { opt -> QuestionOption(opt.label, opt.description) },
multiple = it.multiple,
custom = it.custom,
)
}
return Question(id = dto.id, items = items, tool = ref)
}
private fun String.toDumpText(): String {
val text = lowercase()
.replace("\u2026", "")
.replace("…", "")
.replace(Regex("[^a-z0-9]+"), " ")
.trim()
return text
}
@@ -0,0 +1,32 @@
package ai.kilocode.client.session
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
/**
* Lifecycle events fired by [SessionController] on the EDT.
*
* These cover app/workspace state changes and view switching — things
* outside the [SessionModel] domain. For model mutations (messages,
* parts, state), listen to [SessionModelEvent] on [SessionModel] directly.
*/
sealed class SessionControllerEvent {
// App + workspace lifecycle (every state transition)
data object AppChanged : SessionControllerEvent()
data object WorkspaceChanged : SessionControllerEvent()
// Workspace ready (pickers populated)
data object WorkspaceReady : SessionControllerEvent()
data class ViewChanged(val show: Boolean) : SessionControllerEvent() {
override fun toString() = if (show) "ViewChanged show" else "ViewChanged hide"
}
}
/**
* Listener for [SessionControllerEvent]s fired by [SessionController].
* All callbacks are guaranteed to run on the EDT.
*/
fun interface SessionControllerListener {
fun onEvent(event: SessionControllerEvent)
}
@@ -3,10 +3,10 @@ package ai.kilocode.client.session
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.model.SessionEvent
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.LabelPicker
import ai.kilocode.client.session.ui.MessageListPanel
import ai.kilocode.client.session.ui.MessageListUi
import ai.kilocode.client.session.ui.PromptPanel
import ai.kilocode.client.session.ui.StatusPanel
import com.intellij.openapi.Disposable
@@ -18,18 +18,7 @@ import java.awt.BorderLayout
import java.awt.CardLayout
import javax.swing.JPanel
/**
* Main chat panel — reacts to [SessionModel] events.
*
* Uses [CardLayout] in the center to switch between the empty panel
* (shown before the first prompt) and the scrollable message list.
*
* All business logic (app/workspace watching, session lifecycle, event
* handling, status computation) lives in [SessionModel]. Welcome
* rendering lives in [ai.kilocode.client.session.ui.StatusPanel]. This class handles layout, prompt
* wiring, message list updates, card switching, picker population,
* busy state, and scrolling.
*/
/** Main session panel — reacts to [SessionController] events. */
class SessionUi(
project: Project,
workspace: Workspace,
@@ -43,9 +32,9 @@ class SessionUi(
private const val MESSAGES = "messages"
}
private val model = SessionModel(this, null, sessions, workspace, app, cs)
private val status = StatusPanel(this, model)
private val messages = MessageListPanel()
private val controller = SessionController(this, null, sessions, workspace, app, cs)
private val status = StatusPanel(this, controller)
private val messages = MessageListUi(this, controller.model)
private val cards = CardLayout()
private val center = JPanel(cards)
@@ -57,13 +46,12 @@ class SessionUi(
}
private val prompt = PromptPanel(
project = project,
onSend = { text -> send(text) },
onAbort = { model.abort() },
project = project,
onSend = { text -> send(text) },
onAbort = { controller.abort() },
)
init {
// Layout
center.add(status, STATUS)
center.add(scroll, MESSAGES)
cards.show(center, STATUS)
@@ -71,120 +59,66 @@ class SessionUi(
add(center, BorderLayout.CENTER)
add(prompt, BorderLayout.SOUTH)
// Wire picker callbacks via typed model methods
prompt.mode.onSelect = { item ->
model.selectAgent(item.id)
}
prompt.model.onSelect = { item ->
prompt.mode.onSelect = { item -> controller.selectAgent(item.id) }
prompt.model.onSelect = picker@{ item ->
val group = item.group
if (group != null) {
model.selectModel(group, item.id)
if (group == null) return@picker
controller.selectModel(group, item.id)
}
// Lifecycle events from the manager (app/workspace state, view switching)
controller.addListener(this) { event ->
when (event) {
is SessionControllerEvent.WorkspaceReady -> {
val c = controller.model
prompt.mode.setItems(c.agents.map { LabelPicker.Item(it.name, it.display) }, c.agent)
val items = c.models.map { LabelPicker.Item(it.id, it.display, it.provider) }
val selected = c.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id }
prompt.model.setItems(items, selected)
prompt.setReady(c.isReady())
}
is SessionControllerEvent.ViewChanged -> cards.show(center, if (event.show) MESSAGES else STATUS)
is SessionControllerEvent.AppChanged,
is SessionControllerEvent.WorkspaceChanged -> prompt.setReady(controller.model.isReady())
}
}
// React to model events — no coroutines, pure EDT
model.addListener(this) { event ->
// Model events — state drives the prompt busy state
controller.model.addListener(this) { event ->
when (event) {
is SessionEvent.MessageAdded -> {
val msg = model.chat.message(event.id) ?: return@addListener
messages.addMessage(msg.info)
refreshMessages()
}
is SessionEvent.MessageRemoved -> {
messages.removeMessage(event.id)
refreshMessages()
}
is SessionEvent.PartUpdated -> {
val part = model.chat.part(event.messageId, event.partId) ?: return@addListener
messages.updatePartText(event.messageId, event.partId, part.text.toString())
refreshMessages()
}
is SessionEvent.PartDelta -> {
messages.appendDelta(event.messageId, event.partId, event.delta)
refreshMessages()
}
is SessionEvent.StatusChanged -> {
messages.setStatus(event.text)
refreshMessages()
}
is SessionEvent.Error -> {
messages.addError(event.message)
refreshMessages()
}
is SessionEvent.HistoryLoaded -> {
messages.clear()
for (msg in model.chat.messages()) {
messages.addMessage(msg.info)
for ((partId, part) in msg.parts) {
if (part.dto.type == "text" && part.text.isNotEmpty()) {
messages.updatePartText(msg.info.id, partId, part.text.toString())
}
}
}
refreshMessages()
}
is SessionEvent.Cleared -> {
messages.clear()
refreshMessages()
}
is SessionEvent.WorkspaceReady -> {
val c = model.chat
prompt.mode.setItems(
c.agents.map { LabelPicker.Item(it.name, it.display) },
c.agent,
)
val items = c.models.map { LabelPicker.Item(it.id, it.display, it.provider) }
// chat.model is "provider/modelId", picker items use modelId only.
// Find the matching item and pass its id for selection.
val selected = c.model?.let { full ->
items.firstOrNull { "${it.group}/${it.id}" == full }?.id
}
prompt.model.setItems(items, selected)
prompt.setReady(c.ready)
}
is SessionEvent.ViewChanged -> {
cards.show(center, if (event.show) MESSAGES else STATUS)
}
is SessionEvent.BusyChanged -> {
prompt.setBusy(event.busy)
}
is SessionEvent.AppChanged,
is SessionEvent.WorkspaceChanged -> {
// Handled by StatusPanel
is SessionModelEvent.StateChanged -> {
val busy = event.state.isBusy()
prompt.setBusy(busy)
scrollToBottom()
}
is SessionModelEvent.MessageAdded,
is SessionModelEvent.ContentAdded,
is SessionModelEvent.ContentUpdated,
is SessionModelEvent.ContentDelta -> scrollToBottom()
is SessionModelEvent.HistoryLoaded -> scrollToBottom()
else -> {}
}
}
}
private fun send(text: String) {
if (text.isBlank()) return
model.prompt(text)
controller.prompt(text)
prompt.clear()
}
private fun refreshMessages() {
messages.revalidate()
messages.repaint()
scrollToBottom()
}
private fun scrollToBottom() {
val bar = scroll.verticalScrollBar
bar.value = bar.maximum
}
override fun dispose() {
// All children (status, model) disposed by Disposer
}
override fun dispose() {}
}
private fun SessionState.isBusy(): Boolean = when (this) {
is SessionState.Idle -> false
is SessionState.Error -> false
else -> true
}
@@ -0,0 +1,45 @@
package ai.kilocode.client.session.model
import ai.kilocode.rpc.dto.MessageDto
/** A single message with its typed contents. */
class Message(
val info: MessageDto,
) {
val parts = LinkedHashMap<String, Content>()
}
/** Typed content within a message. */
sealed class Content(val id: String)
/** Streamed text content from the assistant. */
class Text(id: String) : Content(id) {
val content = StringBuilder()
}
/** Model reasoning / chain-of-thought. */
class Reasoning(id: String) : Content(id) {
val content = StringBuilder()
}
/** Tool invocation with lifecycle state. */
class Tool(id: String, val name: String) : Content(id) {
var state: ToolExecState = ToolExecState.PENDING
var title: String? = null
}
/** Context compaction marker. */
class Compaction(id: String) : Content(id)
/**
* Generic fallback for part types not yet given a dedicated class.
* Preserves the [type] string so unknown content is not silently dropped.
*/
class Generic(id: String, val type: String) : Content(id)
enum class ToolExecState { PENDING, RUNNING, COMPLETED, ERROR }
data class ToolCallRef(
val messageId: String,
val callId: String,
)
@@ -0,0 +1,34 @@
package ai.kilocode.client.session.model
enum class PermissionRequestState { PENDING, RESPONDING, RESOLVED, ERROR }
enum class PermissionReply { ONCE, ALWAYS, REJECT }
data class Permission(
val id: String,
val sessionId: String,
val name: String,
val patterns: List<String>,
val always: List<String>,
val meta: PermissionMeta,
val message: String? = null,
val tool: ToolCallRef? = null,
val state: PermissionRequestState = PermissionRequestState.PENDING,
)
data class PermissionMeta(
val rules: List<String> = emptyList(),
val diff: String? = null,
val filePath: String? = null,
val fileDiff: PermissionFileDiff? = null,
val raw: Map<String, String> = emptyMap(),
)
data class PermissionFileDiff(
val file: String,
val patch: String? = null,
val before: String? = null,
val after: String? = null,
val additions: Int,
val deletions: Int,
)
@@ -0,0 +1,23 @@
package ai.kilocode.client.session.model
enum class QuestionRequestState { PENDING, RESPONDING, ERROR }
data class Question(
val id: String,
val items: List<QuestionItem>,
val tool: ToolCallRef? = null,
val state: QuestionRequestState = QuestionRequestState.PENDING,
)
data class QuestionItem(
val question: String,
val header: String,
val options: List<QuestionOption>,
val multiple: Boolean,
val custom: Boolean,
)
data class QuestionOption(
val label: String,
val description: String,
)
@@ -1,45 +0,0 @@
package ai.kilocode.client.session.model
/**
* Change events fired by [SessionModel] on the EDT.
*
* Events carry IDs so the UI knows **which** message/part changed.
* The UI can read full data from [SessionState] directly (safe — same
* EDT thread). [PartDelta] also carries the delta string so the
* view can append efficiently without reading the whole text.
*/
sealed class SessionEvent {
// Message lifecycle
data class MessageAdded(val id: String) : SessionEvent()
data class MessageRemoved(val id: String) : SessionEvent()
// Part changes
data class PartUpdated(val messageId: String, val partId: String) : SessionEvent()
data class PartDelta(val messageId: String, val partId: String, val delta: String) : SessionEvent()
// Session state
data class StatusChanged(val text: String?) : SessionEvent()
data class BusyChanged(val busy: Boolean) : SessionEvent()
data class Error(val message: String) : SessionEvent()
// Bulk operations
data object HistoryLoaded : SessionEvent()
data object Cleared : SessionEvent()
// App + workspace lifecycle (every state transition)
data object AppChanged : SessionEvent()
data object WorkspaceChanged : SessionEvent()
// Workspace ready (pickers populated)
data object WorkspaceReady : SessionEvent()
data class ViewChanged(val show: Boolean) : SessionEvent()
}
/**
* Listener for [SessionEvent]s fired by [SessionModel].
* All callbacks are guaranteed to run on the EDT.
*/
fun interface SessionModelListener {
fun onEvent(event: SessionEvent)
}
@@ -1,350 +1,346 @@
package ai.kilocode.client.session.model
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.TodoDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.util.Disposer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Session lifecycle controller for a single session.
* Pure session model — single source of truth for session content and runtime state.
*
* Accepts an optional [id] — if non-null, loads that session immediately.
* If null, lazily creates a session on the first [prompt] call. This
* ensures event subscription happens *before* the prompt is sent,
* eliminating race conditions.
*
* Owns [SessionState] and the listener list. All model mutations and
* listener notifications happen on the EDT — [fire] auto-dispatches
* via `invokeLater` when called from a background thread.
* **EDT-only access** — no synchronization. [ai.kilocode.client.session.SessionController] guarantees all
* reads and writes happen on the EDT.
*/
class SessionModel(
parent: Disposable,
id: String?,
private val sessions: KiloSessionService,
private val workspace: Workspace,
private val app: KiloAppService,
private val cs: CoroutineScope,
) : Disposable {
class SessionModel {
companion object {
private val LOG = Logger.getInstance(SessionModel::class.java)
}
private val entries = LinkedHashMap<String, Message>()
init {
Disposer.register(parent, this)
}
var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
var version: String? = null
val chat = SessionState()
var workspace: KiloWorkspaceStateDto = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)
var agents: List<AgentItem> = emptyList()
var models: List<ModelItem> = emptyList()
var agent: String? = null
var model: String? = null
var showMessages: Boolean = false
private val listeners = mutableListOf<SessionModelListener>()
var state: SessionState = SessionState.Idle
private set
/** The session ID owned by this model. Null until created or passed in. */
private var sessionId: String? = id
var diff: List<DiffFileDto> = emptyList()
private set
/** Resolved project directory for RPC calls. */
private val directory: String get() = workspace.directory
var todos: List<TodoDto> = emptyList()
private set
// Status computation state (EDT-only)
private var partType: String? = null
private var tool: String? = null
private var busy: Boolean = false
var compactionCount: Int = 0
private set
// Coroutine job for the current event subscription
private var eventJob: Job? = null
private val listeners = mutableListOf<SessionModelEvent.Listener>()
// --- Listener management (EDT) ---
/**
* Register a listener whose lifetime is tied to [parent].
* When [parent] is disposed the listener is auto-removed.
*/
fun addListener(parent: Disposable, listener: SessionModelListener) {
fun addListener(parent: Disposable, listener: SessionModelEvent.Listener) {
listeners.add(listener)
Disposer.register(parent) { listeners.remove(listener) }
}
// --- Actions (called from EDT) ---
fun messages(): Collection<Message> = entries.values
fun message(id: String): Message? = entries[id]
fun content(messageId: String, contentId: String): Content? = entries[messageId]?.parts?.get(contentId)
fun isEmpty(): Boolean = entries.isEmpty()
fun isReady(): Boolean = app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY
/**
* Send a prompt. If no session exists, creates one first,
* subscribes to events, then sends the prompt — all in one
* coroutine to avoid race conditions.
* Add a message if it doesn't exist, or update its [MessageDto] info if it does.
* Returns true when the message was newly added (caller can decide to show messages).
*/
fun prompt(text: String) {
showMessages()
cs.launch {
try {
val id = sessionId ?: run {
val session = sessions.create(directory)
sessionId = session.id
subscribeEvents()
session.id
}
sessions.prompt(id, directory, text)
} catch (e: Exception) {
LOG.warn("prompt failed", e)
edt {
fire(SessionEvent.Error(e.message ?: KiloBundle.message("session.error.prompt")))
fire(SessionEvent.BusyChanged(false))
}
}
fun upsertMessage(dto: MessageDto): Boolean {
val existing = entries[dto.id]
if (existing != null) {
val updated = Message(dto).also { it.parts.putAll(existing.parts) }
entries[dto.id] = updated
fire(SessionModelEvent.MessageUpdated(updated))
return false
}
val msg = Message(dto)
entries[dto.id] = msg
fire(SessionModelEvent.MessageAdded(msg))
return true
}
fun abort() {
val id = sessionId ?: return
cs.launch {
try {
sessions.abort(id, directory)
} catch (e: Exception) {
LOG.warn("abort failed", e)
}
}
/** @deprecated Use [upsertMessage] instead. Kept for incremental migration. */
fun addMessage(dto: MessageDto): Message? {
if (entries.containsKey(dto.id)) return null
val msg = Message(dto)
entries[dto.id] = msg
fire(SessionModelEvent.MessageAdded(msg))
return msg
}
fun selectAgent(name: String) {
chat.agent = name
cs.launch {
try {
sessions.updateConfig(directory, ConfigUpdateDto(agent = name))
} catch (e: Exception) {
LOG.warn("selectAgent failed", e)
}
}
fire(SessionEvent.WorkspaceReady)
fun removeMessage(id: String) {
if (entries.remove(id) == null) return
fire(SessionModelEvent.MessageRemoved(id))
}
fun selectModel(provider: String, id: String) {
chat.model = "$provider/$id"
cs.launch {
try {
sessions.updateConfig(directory, ConfigUpdateDto(model = "$provider/$id"))
} catch (e: Exception) {
LOG.warn("selectModel failed", e)
}
}
fire(SessionEvent.WorkspaceReady)
fun removeContent(messageId: String, contentId: String) {
val msg = entries[messageId] ?: return
if (msg.parts.remove(contentId) == null) return
fire(SessionModelEvent.ContentRemoved(messageId, contentId))
}
// --- Internal: coroutine → EDT bridge ---
init {
// If we have a session ID, load it immediately
if (sessionId != null) {
loadHistory()
subscribeEvents()
}
// Watch session statuses for busy/idle
cs.launch {
sessions.statuses.collect { statuses ->
val id = sessionId ?: return@collect
val st = statuses[id]
edt { fire(SessionEvent.BusyChanged(st?.type == "busy")) }
}
}
// Watch app lifecycle state
app.connect()
cs.launch {
app.state.collect { state ->
if (state.status == KiloAppStatusDto.READY) app.fetchVersionAsync()
edt {
chat.app = state
chat.version = app.version
fire(SessionEvent.AppChanged)
}
}
}
// Watch workspace state for providers/agents and lifecycle
cs.launch {
workspace.state.collect { state ->
edt {
chat.workspace = state
fire(SessionEvent.WorkspaceChanged)
if (state.status == KiloWorkspaceStatusDto.READY) {
chat.agents = state.agents?.agents?.map {
AgentItem(it.name, it.displayName ?: it.name)
} ?: emptyList()
chat.models = state.providers?.let { providers ->
providers.providers
.filter { it.id in providers.connected }
.flatMap { provider ->
provider.models.map { (id, info) ->
ModelItem(id, info.name, provider.id)
}
}
} ?: emptyList()
if (chat.agent == null) {
chat.agent = state.agents?.default
}
if (chat.model == null) {
chat.model = state.providers?.defaults?.entries?.firstOrNull()?.value
}
chat.ready = true
fire(SessionEvent.WorkspaceReady)
}
}
}
fun updateContent(messageId: String, dto: PartDto) {
val msg = entries[messageId] ?: return
val existing = msg.parts[dto.id]
if (existing != null) {
updateExisting(messageId, existing, dto)
return
}
val content = fromDto(dto)
msg.parts[dto.id] = content
fire(SessionModelEvent.ContentAdded(messageId, content))
}
private fun loadHistory() {
val id = sessionId ?: return
cs.launch {
try {
val history = sessions.messages(id, directory)
edt {
chat.load(history)
if (!chat.isEmpty()) showMessages()
fire(SessionEvent.HistoryLoaded)
}
} catch (e: Exception) {
LOG.warn("loadHistory failed", e)
fun appendDelta(messageId: String, contentId: String, delta: String) {
val msg = entries[messageId] ?: return
val existing = msg.parts[contentId]
if (existing != null) {
val buf = when (existing) {
is Text -> existing.content
is Reasoning -> existing.content
else -> return
}
}
}
private fun subscribeEvents() {
val id = sessionId ?: return
eventJob?.cancel()
eventJob = cs.launch {
sessions.events(id, directory).collect { event ->
edt { handle(event) }
}
}
}
private fun handle(event: ChatEventDto) {
when (event) {
is ChatEventDto.MessageUpdated -> {
chat.addMessage(event.info)
showMessages()
fire(SessionEvent.MessageAdded(event.info.id))
}
is ChatEventDto.PartUpdated -> {
partType = event.part.type
tool = event.part.tool
chat.updatePart(event.part.messageID, event.part)
if (busy) {
fire(SessionEvent.StatusChanged(status()))
}
if (event.part.type == "text" && event.part.text != null) {
fire(SessionEvent.PartUpdated(event.part.messageID, event.part.id))
}
}
is ChatEventDto.PartDelta -> {
if (event.field == "text") {
chat.appendDelta(event.messageID, event.partID, event.delta)
fire(SessionEvent.PartDelta(event.messageID, event.partID, event.delta))
}
}
is ChatEventDto.TurnOpen -> {
partType = null
tool = null
busy = true
fire(SessionEvent.StatusChanged(KiloBundle.message("session.status.considering")))
fire(SessionEvent.BusyChanged(true))
}
is ChatEventDto.TurnClose -> {
partType = null
tool = null
busy = false
fire(SessionEvent.StatusChanged(null))
fire(SessionEvent.BusyChanged(false))
}
is ChatEventDto.Error -> {
val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown")
busy = false
fire(SessionEvent.Error(msg))
fire(SessionEvent.StatusChanged(null))
fire(SessionEvent.BusyChanged(false))
}
is ChatEventDto.MessageRemoved -> {
chat.removeMessage(event.messageID)
fire(SessionEvent.MessageRemoved(event.messageID))
}
}
}
// --- View switching (EDT) ---
private fun showMessages() {
if (!chat.showMessages) {
chat.showMessages = true
fire(SessionEvent.ViewChanged(true))
}
}
private fun hideMessages() {
if (chat.showMessages) {
chat.showMessages = false
fire(SessionEvent.ViewChanged(false))
}
}
/**
* Compute a human-readable status from the last streaming part.
*/
private fun status(): String = when (partType) {
"reasoning" -> KiloBundle.message("session.status.thinking")
"text" -> KiloBundle.message("session.status.writing")
"tool" -> when (tool) {
"task" -> KiloBundle.message("session.status.delegating")
"todowrite", "todoread" -> KiloBundle.message("session.status.planning")
"read" -> KiloBundle.message("session.status.gathering")
"glob", "grep", "list" -> KiloBundle.message("session.status.searching.codebase")
"webfetch", "websearch", "codesearch" -> KiloBundle.message("session.status.searching.web")
"edit", "write" -> KiloBundle.message("session.status.editing")
"bash" -> KiloBundle.message("session.status.commands")
else -> KiloBundle.message("session.status.considering")
}
else -> KiloBundle.message("session.status.considering")
}
/**
* Notify all listeners. If called from the EDT, listeners run
* immediately. If called from a background thread, the notification
* is dispatched via `invokeLater`.
*/
private fun fire(event: SessionEvent) {
val application = ApplicationManager.getApplication()
if (application.isDispatchThread) {
for (l in listeners) l.onEvent(event)
buf.append(delta)
} else {
application.invokeLater { for (l in listeners) l.onEvent(event) }
val content = Text(contentId)
content.content.append(delta)
msg.parts[contentId] = content
fire(SessionModelEvent.ContentAdded(messageId, content))
}
fire(SessionModelEvent.ContentDelta(messageId, contentId, delta))
}
fun setState(state: SessionState) {
this.state = state
fire(SessionModelEvent.StateChanged(state))
}
fun setDiff(diff: List<DiffFileDto>) {
this.diff = diff
fire(SessionModelEvent.DiffUpdated(diff))
}
fun setTodos(todos: List<TodoDto>) {
this.todos = todos
fire(SessionModelEvent.TodosUpdated(todos))
}
fun markCompacted() {
compactionCount++
fire(SessionModelEvent.Compacted(compactionCount))
}
fun loadHistory(history: List<MessageWithPartsDto>) {
entries.clear()
state = SessionState.Idle
diff = emptyList()
todos = emptyList()
compactionCount = 0
for (msg in history) {
val item = Message(msg.info)
for (part in msg.parts) {
val content = fromDto(part, part.text)
item.parts[content.id] = content
}
entries[msg.info.id] = item
}
fire(SessionModelEvent.HistoryLoaded)
}
fun clear() {
entries.clear()
state = SessionState.Idle
diff = emptyList()
todos = emptyList()
compactionCount = 0
fire(SessionModelEvent.Cleared)
}
private fun updateExisting(messageId: String, existing: Content, dto: PartDto) {
when (existing) {
is Text -> {
val text = dto.text ?: return
existing.content.clear()
existing.content.append(text)
}
is Reasoning -> {
val text = dto.text ?: return
existing.content.clear()
existing.content.append(text)
}
is Tool -> {
existing.state = parseToolState(dto.state)
existing.title = dto.title
}
is Compaction -> return
is Generic -> return
}
fire(SessionModelEvent.ContentUpdated(messageId, existing))
}
private fun fromDto(dto: PartDto, text: CharSequence? = null): Content {
val content = text ?: dto.text
return when (dto.type) {
"text" -> Text(dto.id).apply {
if (content != null && content.isNotEmpty()) this.content.append(content)
}
"reasoning" -> Reasoning(dto.id).apply {
if (content != null && content.isNotEmpty()) this.content.append(content)
}
"tool" -> Tool(dto.id, dto.tool ?: "unknown").apply {
state = parseToolState(dto.state)
title = dto.title
}
"compaction" -> Compaction(dto.id)
else -> Generic(dto.id, dto.type)
}
}
private fun edt(block: () -> Unit) {
ApplicationManager.getApplication().invokeLater(block)
private fun fire(event: SessionModelEvent) {
for (l in listeners) l.onEvent(event)
}
override fun dispose() {
eventJob?.cancel()
cs.cancel()
override fun toString(): String {
val out = mutableListOf<String>()
for (msg in messages()) {
if (out.isNotEmpty()) out.add("---")
out.addAll(renderMessage(msg))
}
when (val state = this.state) {
is SessionState.AwaitingQuestion -> {
if (out.isNotEmpty()) out.add("---")
out.addAll(renderQuestion(state.question))
}
is SessionState.AwaitingPermission -> {
if (out.isNotEmpty()) out.add("---")
out.addAll(renderPermission(state.permission))
}
else -> {}
}
if (diff.isNotEmpty()) {
if (out.isNotEmpty()) out.add("---")
out.add("diff: ${diff.joinToString(" ") { it.file }}")
}
if (todos.isNotEmpty()) {
if (out.isNotEmpty()) out.add("---")
todos.forEach { out.add("todo: [${it.status}] ${it.content}") }
}
if (compactionCount > 0) {
if (out.isNotEmpty()) out.add("---")
out.add("compacted: $compactionCount")
}
return out.joinToString("\n")
}
}
private fun parseToolState(raw: String?): ToolExecState = when (raw) {
"pending" -> ToolExecState.PENDING
"running" -> ToolExecState.RUNNING
"completed" -> ToolExecState.COMPLETED
"error" -> ToolExecState.ERROR
else -> ToolExecState.PENDING
}
data class AgentItem(val name: String, val display: String)
data class ModelItem(val id: String, val display: String, val provider: String)
private fun renderMessage(msg: Message): List<String> {
val out = mutableListOf<String>()
out.add("${msg.info.role}#${msg.info.id}")
for (part in msg.parts.values) {
when (part) {
is Text -> {
out.add("text#${part.id}:")
out.addAll(renderText(part.content))
}
is Reasoning -> {
out.add("reasoning#${part.id}:")
out.addAll(renderText(part.content))
}
is Tool -> out.add(renderTool(part))
is Compaction -> out.add("compaction#${part.id}")
is Generic -> out.add("${part.type}#${part.id}")
}
}
return out
}
private fun renderQuestion(question: Question): List<String> {
val out = mutableListOf<String>()
out.add("question#${question.id}")
out.add("tool: ${renderToolRef(question.tool)}")
for (item in question.items) {
out.add("header: ${item.header}")
out.add("prompt: ${item.question}")
for (opt in item.options) {
out.add("option: ${opt.label} - ${opt.description}")
}
out.add("multiple: ${item.multiple}")
out.add("custom: ${item.custom}")
}
return out
}
private fun renderPermission(permission: Permission): List<String> {
val out = mutableListOf<String>()
out.add("permission#${permission.id}")
out.add("tool: ${renderToolRef(permission.tool)}")
out.add("name: ${permission.name}")
out.add("patterns: ${permission.patterns.joinToString(", ").ifEmpty { "<none>" }}")
out.add("always: ${permission.always.joinToString(", ").ifEmpty { "<none>" }}")
out.add("file: ${renderFile(permission.meta)}")
out.add("state: ${permission.state.name}")
val meta = permission.meta.raw.entries
.filter { it.key !in setOf("file", "path", "state") }
.sortedBy { it.key }
.joinToString(", ") { "${it.key}=${it.value}" }
.ifEmpty { "<none>" }
out.add("metadata: $meta")
return out
}
private fun renderToolRef(ref: ToolCallRef?): String = ref?.let { "${it.messageId}/${it.callId}" } ?: "<none>"
private fun renderFile(meta: PermissionMeta): String {
meta.filePath?.takeIf { it.isNotBlank() }?.let { return it }
meta.raw["file"]?.takeIf { it.isNotBlank() }?.let { return it }
meta.raw["path"]?.takeIf { it.isNotBlank() }?.let { return it }
return "<none>"
}
private fun renderTool(tool: Tool): String {
val state = tool.state.name
val title = tool.title?.takeIf { it.isNotBlank() }?.let { " $it" } ?: ""
return "tool#${tool.id} ${tool.name} [$state]$title"
}
private fun renderText(text: CharSequence): List<String> {
val raw = text.toString()
if (raw.isEmpty()) return listOf(" <empty>")
return raw.split("\n").map { " $it" }
}
@@ -0,0 +1,52 @@
package ai.kilocode.client.session.model
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.TodoDto
/**
* Change events fired by [SessionModel].
*
* Events carry the data needed for rendering so UI can update without
* reading back from the model except for [HistoryLoaded].
*/
sealed class SessionModelEvent {
data class MessageAdded(val info: Message) : SessionModelEvent() {
override fun toString() = "MessageAdded ${info.info.id}"
}
data class MessageUpdated(val info: Message) : SessionModelEvent() {
override fun toString() = "MessageUpdated ${info.info.id}"
}
data class MessageRemoved(val id: String) : SessionModelEvent() {
override fun toString() = "MessageRemoved $id"
}
data class ContentAdded(val messageId: String, val content: Content) : SessionModelEvent() {
override fun toString() = "ContentAdded $messageId/${content.id}"
}
data class ContentUpdated(val messageId: String, val content: Content) : SessionModelEvent() {
override fun toString() = "ContentUpdated $messageId/${content.id}"
}
data class ContentRemoved(val messageId: String, val contentId: String) : SessionModelEvent() {
override fun toString() = "ContentRemoved $messageId/$contentId"
}
data class ContentDelta(val messageId: String, val contentId: String, val delta: String) : SessionModelEvent() {
override fun toString() = "ContentDelta $messageId/$contentId"
}
data class StateChanged(val state: SessionState) : SessionModelEvent() {
override fun toString() = "StateChanged ${state::class.simpleName}"
}
data class DiffUpdated(val diff: List<DiffFileDto>) : SessionModelEvent() {
override fun toString() = "DiffUpdated files=${diff.size}"
}
data class TodosUpdated(val todos: List<TodoDto>) : SessionModelEvent() {
override fun toString() = "TodosUpdated count=${todos.size}"
}
data class Compacted(val count: Int) : SessionModelEvent() {
override fun toString() = "Compacted count=$count"
}
data object HistoryLoaded : SessionModelEvent()
data object Cleared : SessionModelEvent()
fun interface Listener {
fun onEvent(event: SessionModelEvent)
}
}
@@ -1,121 +1,18 @@
package ai.kilocode.client.session.model
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PartDto
/** Single source of truth for what a session is doing right now. */
sealed class SessionState {
data object Idle : SessionState()
/**
* Pure data holder for the active session's messages, parts, and
* workspace state (agents, models, selection).
*
* **EDT-only access** — no synchronization. [SessionModel] guarantees
* all reads and writes happen on the EDT.
*/
class SessionState {
data class Busy(val text: String) : SessionState()
private val messages = LinkedHashMap<String, MessageData>()
data class AwaitingQuestion(val question: Question) : SessionState()
// --- App lifecycle state (set by SessionModel, read by EmptyChatUi) ---
data class AwaitingPermission(val permission: Permission) : SessionState()
var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
var version: String? = null
data class Retry(val message: String, val attempt: Int, val next: Long) : SessionState()
// --- Workspace state (set by SessionModel, read by UI) ---
data class Offline(val message: String, val requestId: String) : SessionState()
var workspace: KiloWorkspaceStateDto = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)
var agents: List<AgentItem> = emptyList()
var models: List<ModelItem> = emptyList()
var agent: String? = null
var model: String? = null
var ready: Boolean = false
var showMessages: Boolean = false
// --- Read ---
fun message(id: String): MessageData? = messages[id]
fun messages(): Collection<MessageData> = messages.values
fun part(messageId: String, partId: String): PartData? =
messages[messageId]?.parts?.get(partId)
fun isEmpty(): Boolean = messages.isEmpty()
// --- Write (called by SessionModel on EDT) ---
/**
* Add a message. Returns false if the message already exists.
*/
fun addMessage(info: MessageDto): Boolean {
if (messages.containsKey(info.id)) return false
messages[info.id] = MessageData(info, LinkedHashMap())
return true
}
/**
* Remove a message by ID. Returns false if not found.
*/
fun removeMessage(id: String): Boolean =
messages.remove(id) != null
/**
* Create or replace a part entry and set its text from [PartDto.text].
*/
fun updatePart(messageId: String, part: PartDto) {
val msg = messages[messageId] ?: return
val text = StringBuilder(part.text ?: "")
msg.parts[part.id] = PartData(part, text)
}
/**
* Append a text delta to an existing part. Creates the part if missing.
*/
fun appendDelta(messageId: String, partId: String, delta: String) {
val msg = messages[messageId] ?: return
val existing = msg.parts[partId]
if (existing != null) {
existing.text.append(delta)
} else {
msg.parts[partId] = PartData(
PartDto(id = partId, sessionID = "", messageID = messageId, type = "text"),
StringBuilder(delta),
)
}
}
/**
* Bulk-load message history from RPC DTOs. Clears existing data first.
*/
fun load(history: List<MessageWithPartsDto>) {
messages.clear()
for (msg in history) {
val parts = LinkedHashMap<String, PartData>()
for (part in msg.parts) {
parts[part.id] = PartData(part, StringBuilder(part.text ?: ""))
}
messages[msg.info.id] = MessageData(msg.info, parts)
}
}
fun clear() {
messages.clear()
}
data class Error(val message: String, val kind: String? = null) : SessionState()
}
data class MessageData(
val info: MessageDto,
val parts: LinkedHashMap<String, PartData>,
)
class PartData(
val dto: PartDto,
val text: StringBuilder,
)
data class AgentItem(val name: String, val display: String)
data class ModelItem(val id: String, val display: String, val provider: String)
@@ -1,165 +0,0 @@
package ai.kilocode.client.session.ui
import ai.kilocode.rpc.dto.MessageDto
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.FlowLayout
import javax.swing.BoxLayout
import javax.swing.JPanel
import javax.swing.JTextArea
import javax.swing.border.MatteBorder
/**
* Scrollable panel displaying chat messages aligned to the top,
* with an optional animated status indicator at the bottom.
*
* Inner panel uses [BoxLayout.Y_AXIS] for stacking, wrapped in a
* [BorderLayout.NORTH] so messages stay top-aligned when the scroll
* viewport is taller than the content.
*/
class MessageListPanel : JPanel(BorderLayout()) {
private val panels = LinkedHashMap<String, MessageBlock>()
private val inner = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
border = JBUI.Borders.empty(4, 8)
}
private val statusLabel = JBLabel().apply {
foreground = UIUtil.getContextHelpForeground()
}
private val status = JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), 0)).apply {
isOpaque = false
isVisible = false
border = JBUI.Borders.empty(6, 0)
alignmentX = LEFT_ALIGNMENT
add(JBLabel(AnimatedIcon.Default()))
add(statusLabel)
}
init {
isOpaque = true
background = UIUtil.getPanelBackground()
inner.add(status)
add(inner, BorderLayout.NORTH)
}
fun addMessage(info: MessageDto) {
if (panels.containsKey(info.id)) return
val block = MessageBlock(info)
panels[info.id] = block
// Insert before the status row (which is always last)
inner.add(block, inner.componentCount - 1)
revalidate()
repaint()
}
fun updatePartText(messageID: String, partID: String, text: String) {
panels[messageID]?.setText(partID, text)
revalidate()
repaint()
}
fun appendDelta(messageID: String, partID: String, delta: String) {
panels[messageID]?.appendDelta(partID, delta)
revalidate()
repaint()
}
fun removeMessage(messageID: String) {
val block = panels.remove(messageID) ?: return
inner.remove(block)
revalidate()
repaint()
}
fun addError(msg: String) {
val label = JBLabel(msg).apply {
foreground = JBColor.RED
font = JBUI.Fonts.label()
border = JBUI.Borders.empty(4, 0)
alignmentX = LEFT_ALIGNMENT
}
inner.add(label, inner.componentCount - 1)
revalidate()
repaint()
}
/**
* Show or hide the working status indicator at the bottom of the list.
* Pass null to hide, a string to show with animated spinner.
*/
fun setStatus(text: String?) {
if (text != null) {
statusLabel.text = text
status.isVisible = true
} else {
status.isVisible = false
}
revalidate()
repaint()
}
fun clear() {
panels.clear()
inner.removeAll()
// Re-add status row (always last)
inner.add(status)
status.isVisible = false
revalidate()
repaint()
}
}
/**
* A single message block — text content only, no role header.
* User messages get a thin top border as separator.
*/
private class MessageBlock(info: MessageDto) : JPanel() {
private val parts = LinkedHashMap<String, JTextArea>()
init {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
alignmentX = LEFT_ALIGNMENT
border = if (info.role == "user") {
JBUI.Borders.compound(
MatteBorder(1, 0, 0, 0, JBColor.border()),
JBUI.Borders.empty(8, 0, 4, 0),
)
} else {
JBUI.Borders.empty(4, 0)
}
}
fun setText(partID: String, text: String) {
val area = parts.getOrPut(partID) { createArea().also { add(it) } }
area.text = text
revalidate()
}
fun appendDelta(partID: String, delta: String) {
val area = parts.getOrPut(partID) { createArea().also { add(it) } }
area.append(delta)
revalidate()
}
private fun createArea() = JTextArea().apply {
isEditable = false
lineWrap = true
wrapStyleWord = true
isOpaque = false
font = JBUI.Fonts.label()
foreground = UIUtil.getLabelForeground()
border = JBUI.Borders.empty()
alignmentX = LEFT_ALIGNMENT
}
}
@@ -0,0 +1,279 @@
package ai.kilocode.client.session.ui
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.session.model.Compaction
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Generic
import ai.kilocode.client.session.model.Message
import ai.kilocode.client.session.model.Reasoning
import ai.kilocode.client.session.model.Text
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import com.intellij.openapi.Disposable
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.FlowLayout
import javax.swing.BoxLayout
import javax.swing.JPanel
import javax.swing.JTextArea
import javax.swing.border.MatteBorder
/**
* Scrollable panel displaying session messages aligned to the top,
* with an optional animated status indicator at the bottom.
*
* Passive view — all rendering is driven by [SessionModelEvent]s
* from the [SessionModel]. No public mutation methods.
*/
class MessageListUi(
parent: Disposable,
private val model: SessionModel,
) : JPanel(BorderLayout()) {
private val blocks = LinkedHashMap<String, MessageBlock>()
private var errorLabel: JBLabel? = null
private val inner = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
border = JBUI.Borders.empty(4, 8)
}
private val label = JBLabel().apply {
foreground = UIUtil.getContextHelpForeground()
}
private val spinner = JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), 0)).apply {
isOpaque = false
isVisible = false
border = JBUI.Borders.empty(6, 0)
alignmentX = LEFT_ALIGNMENT
add(JBLabel(AnimatedIcon.Default()))
add(label)
}
init {
isOpaque = true
background = UIUtil.getPanelBackground()
inner.add(spinner)
add(inner, BorderLayout.NORTH)
model.addListener(parent) { event ->
when (event) {
is SessionModelEvent.MessageAdded -> onAdded(event.info)
is SessionModelEvent.MessageUpdated -> onAdded(event.info) // refresh/upsert
is SessionModelEvent.MessageRemoved -> onRemoved(event.id)
is SessionModelEvent.ContentAdded -> onContentAdded(event.messageId, event.content)
is SessionModelEvent.ContentUpdated -> onContentUpdated(event.messageId, event.content)
is SessionModelEvent.ContentRemoved -> onContentRemoved(event.messageId, event.contentId)
is SessionModelEvent.ContentDelta -> onContentDelta(event.messageId, event.contentId, event.delta)
is SessionModelEvent.StateChanged -> onState(event.state)
is SessionModelEvent.HistoryLoaded -> onHistory()
is SessionModelEvent.Cleared -> onCleared()
is SessionModelEvent.DiffUpdated,
is SessionModelEvent.TodosUpdated,
is SessionModelEvent.Compacted -> Unit
}
}
}
private fun onAdded(info: Message) {
if (blocks.containsKey(info.info.id)) return
val block = MessageBlock(info)
blocks[info.info.id] = block
inner.add(block, inner.componentCount - 1)
refresh()
}
private fun onRemoved(id: String) {
val block = blocks.remove(id) ?: return
inner.remove(block)
refresh()
}
private fun onContentAdded(messageId: String, content: Content) {
blocks[messageId]?.addContent(content)
refresh()
}
private fun onContentUpdated(messageId: String, content: Content) {
blocks[messageId]?.updateContent(content)
refresh()
}
private fun onContentRemoved(messageId: String, contentId: String) {
blocks[messageId]?.removeContent(contentId)
refresh()
}
private fun onContentDelta(messageId: String, contentId: String, delta: String) {
blocks[messageId]?.appendDelta(contentId, delta)
refresh()
}
private fun onState(state: SessionState) {
errorLabel?.let { inner.remove(it); errorLabel = null }
when (state) {
is SessionState.Busy -> {
label.text = state.text
spinner.isVisible = true
}
is SessionState.Error -> {
spinner.isVisible = false
val err = JBLabel(state.message).apply {
foreground = JBColor.RED
font = JBUI.Fonts.label()
border = JBUI.Borders.empty(4, 0)
alignmentX = LEFT_ALIGNMENT
}
errorLabel = err
inner.add(err, inner.componentCount - 1)
}
else -> {
spinner.isVisible = false
}
}
refresh()
}
private fun onHistory() {
clear()
for (entry in model.messages()) {
val block = MessageBlock(entry)
blocks[entry.info.id] = block
inner.add(block, inner.componentCount - 1)
for ((_, content) in entry.parts) block.addContent(content)
}
refresh()
}
private fun onCleared() {
clear()
refresh()
}
private fun clear() {
blocks.clear()
errorLabel = null
inner.removeAll()
inner.add(spinner)
spinner.isVisible = false
}
private fun refresh() {
revalidate()
repaint()
}
}
private class MessageBlock(info: Message) : JPanel() {
private val areas = LinkedHashMap<String, JTextArea>()
private val labels = LinkedHashMap<String, JBLabel>()
init {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
alignmentX = LEFT_ALIGNMENT
border = if (info.info.role == "user") {
JBUI.Borders.compound(
MatteBorder(1, 0, 0, 0, JBColor.border()),
JBUI.Borders.empty(8, 0, 4, 0),
)
} else {
JBUI.Borders.empty(4, 0)
}
}
fun addContent(content: Content) {
when (content) {
is Text -> {
val area = createArea()
if (content.content.isNotEmpty()) area.text = content.content.toString()
areas[content.id] = area
add(area)
}
is Reasoning -> {
val area = createArea().apply {
foreground = UIUtil.getContextHelpForeground()
}
if (content.content.isNotEmpty()) area.text = content.content.toString()
areas[content.id] = area
add(area)
}
is Tool -> {
val lbl = createToolLabel(content)
labels[content.id] = lbl
add(lbl)
}
is Compaction -> {
val lbl = JBLabel("Context compacted").apply {
foreground = UIUtil.getContextHelpForeground()
font = JBUI.Fonts.smallFont()
border = JBUI.Borders.empty(4, 0)
alignmentX = LEFT_ALIGNMENT
}
labels[content.id] = lbl
add(lbl)
}
is Generic -> {} // unknown part type — not rendered in this pass
}
revalidate()
}
fun removeContent(contentId: String) {
areas.remove(contentId)?.let { remove(it) }
labels.remove(contentId)?.let { remove(it) }
revalidate()
}
fun updateContent(content: Content) {
when (content) {
is Text -> areas[content.id]?.text = content.content.toString()
is Reasoning -> areas[content.id]?.text = content.content.toString()
is Tool -> labels[content.id]?.text = toolText(content)
is Compaction -> {}
is Generic -> {}
}
revalidate()
}
fun appendDelta(contentId: String, delta: String) {
areas[contentId]?.append(delta)
revalidate()
}
private fun createArea() = JTextArea().apply {
isEditable = false
lineWrap = true
wrapStyleWord = true
isOpaque = false
font = JBUI.Fonts.label()
foreground = UIUtil.getLabelForeground()
border = JBUI.Borders.empty()
alignmentX = LEFT_ALIGNMENT
}
private fun createToolLabel(content: Tool) = JBLabel(toolText(content)).apply {
foreground = UIUtil.getContextHelpForeground()
font = JBUI.Fonts.smallFont()
border = JBUI.Borders.empty(2, 0)
alignmentX = LEFT_ALIGNMENT
}
private fun toolText(content: Tool): String {
val icon = when (content.state) {
ToolExecState.PENDING -> "\u23F3"
ToolExecState.RUNNING -> "\u25B6"
ToolExecState.COMPLETED -> "\u2713"
ToolExecState.ERROR -> "\u2717"
}
return "$icon ${content.title ?: content.name}"
}
}
@@ -1,9 +1,9 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.session.model.SessionEvent
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelListener
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionController
import ai.kilocode.client.session.SessionControllerEvent
import ai.kilocode.client.session.SessionControllerListener
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
@@ -29,8 +29,8 @@ import javax.swing.SwingConstants
/**
* Welcome panel showing app + workspace initialization progress.
*
* Pure view — listens to [SessionModel] events and reads
* [SessionState][ai.kilocode.client.session.model.SessionState] for data.
* Pure view — listens to [SessionController] events and reads
* [SessionModel][ai.kilocode.client.session.model.SessionModel] for data.
* No coroutines, no service references.
*
* Uses icon+label rows for each resource being loaded. Icons act as
@@ -39,8 +39,8 @@ import javax.swing.SwingConstants
*/
class StatusPanel(
parent: Disposable,
private val model: SessionModel,
) : JPanel(GridBagLayout()), SessionModelListener, Disposable {
private val controller: SessionController,
) : JPanel(GridBagLayout()), SessionControllerListener, Disposable {
init {
Disposer.register(parent, this)
@@ -110,19 +110,19 @@ class StatusPanel(
add(body, GridBagConstraints())
resetAll()
model.addListener(this, this)
controller.addListener(this, this)
}
override fun onEvent(event: SessionEvent) {
override fun onEvent(event: SessionControllerEvent) {
when (event) {
is SessionEvent.AppChanged -> {
renderApp(model.chat.app)
is SessionControllerEvent.AppChanged -> {
renderApp(controller.model.app)
revalidate()
repaint()
}
is SessionEvent.WorkspaceChanged -> {
renderWorkspace(model.chat.workspace)
is SessionControllerEvent.WorkspaceChanged -> {
renderWorkspace(controller.model.workspace)
revalidate()
repaint()
}
@@ -187,7 +187,7 @@ class StatusPanel(
}
private fun renderWorkspace(state: KiloWorkspaceStateDto) {
val appReady = model.chat.app.status == KiloAppStatusDto.READY
val appReady = controller.model.app.status == KiloAppStatusDto.READY
val visible = appReady || state.status != KiloWorkspaceStatusDto.PENDING
wsSection.isVisible = visible
if (!visible) return
@@ -241,7 +241,7 @@ class StatusPanel(
KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting")
KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading")
KiloAppStatusDto.READY -> {
val ver = model.chat.version
val ver = controller.model.version
if (ver != null) KiloBundle.message("toolwindow.status.connected.version", ver)
else KiloBundle.message("toolwindow.status.connected")
}
@@ -0,0 +1,298 @@
package ai.kilocode.client.ui.md
import com.intellij.ui.components.JBHtmlPane
import com.intellij.ui.components.JBHtmlPaneConfiguration
import com.intellij.ui.components.JBHtmlPaneStyleConfiguration
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import org.commonmark.ext.autolink.AutolinkExtension
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
import org.commonmark.ext.gfm.tables.TablesExtension
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import java.awt.Color
import java.awt.Font
import java.awt.Point
import javax.swing.JComponent
import javax.swing.event.HyperlinkEvent
import javax.swing.text.html.StyleSheet
/**
* Markdown rendering component backed by [JBHtmlPane] with editor-aware styling.
*
* By default, font and colors are derived from the global editor colour scheme.
* All style properties are optional overrides on top of those defaults.
* Call [resetStyles] to revert to editor defaults after overriding.
*
* Create instances via [MdView.html]. All public methods must be called on the EDT.
*/
@Suppress("UnstableApiUsage")
abstract class MdView private constructor() {
abstract val component: JComponent
abstract fun set(text: String)
abstract fun append(delta: String)
abstract fun clear()
/** Revert all style overrides to editor-derived defaults. */
abstract fun resetStyles()
abstract fun addLinkListener(listener: LinkListener)
abstract fun removeLinkListener(listener: LinkListener)
abstract var font: Font
abstract var foreground: Color
abstract var background: Color
abstract var linkColor: Color
abstract var codeBg: Color
abstract var preBg: Color
abstract var preFg: Color
abstract var codeFont: String
abstract var quoteBorder: Color
abstract var quoteFg: Color
abstract var tableBorder: Color
/**
* When `false`, the component is transparent — the parent's background shows through
* and no background is forced in the CSS body rule.
*/
abstract var opaque: Boolean
data class LinkEvent(
val href: String,
val point: Point? = null,
)
fun interface LinkListener {
fun onLink(event: LinkEvent)
}
internal abstract fun markdown(): String
internal abstract fun html(): String
/** Returns the current CSS override rules applied on top of JBHtmlPane's default stylesheet. */
internal abstract fun overrideSheet(): String
internal abstract fun simulateLink(href: String)
companion object {
fun html(): MdView = HtmlImpl()
}
@Suppress("UnstableApiUsage")
private class HtmlImpl : MdView() {
private val listeners = mutableListOf<LinkListener>()
private val source = StringBuilder()
private var rendered = ""
private val extensions = listOf(
AutolinkExtension.create(),
TablesExtension.create(),
StrikethroughExtension.create(),
)
private val parser: Parser = Parser.builder().extensions(extensions).build()
private val renderer: HtmlRenderer = HtmlRenderer.builder()
.extensions(extensions)
.escapeHtml(true)
.sanitizeUrls(true)
.build()
// nullable overrides — null means "use JBHtmlPane / editor default"
private var fontOverride: Font? = null
private var foregroundOverride: Color? = null
private var backgroundOverride: Color? = null
private var linkColorOverride: Color? = null
private var codeBgOverride: Color? = null
private var preBgOverride: Color? = null
private var preFgOverride: Color? = null
private var codeFontOverride: String? = null
private var quoteBorderOverride: Color? = null
private var quoteFgOverride: Color? = null
private var tableBorderOverride: Color? = null
private var opaqueState = true
private val pane: JBHtmlPane = JBHtmlPane(
JBHtmlPaneStyleConfiguration {
// colorSchemeProvider defaults to EditorColorsManager.getInstance().globalScheme
enableInlineCodeBackground = true
enableCodeBlocksBackground = true
},
JBHtmlPaneConfiguration {
// fontResolver defaults to EditorCssFontResolver.getGlobalInstance() via JBHtmlPane's ImplService
customStyleSheetProvider { buildOverrideStyleSheet() }
}
).apply {
isEditable = false
isOpaque = true
background = UIUtil.getPanelBackground()
addHyperlinkListener { e ->
if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) {
val href = e.description ?: return@addHyperlinkListener
val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point
val event = LinkEvent(href, pt)
for (l in listeners) l.onLink(event)
}
}
}
override val component: JComponent get() = pane
// -- style properties (non-null API backed by nullable overrides) ----
override var font: Font
get() = fontOverride ?: JBUI.Fonts.label()
set(value) { fontOverride = value; markDirty() }
override var foreground: Color
get() = foregroundOverride ?: UIUtil.getLabelForeground()
set(value) { foregroundOverride = value; markDirty() }
override var background: Color
get() = backgroundOverride ?: pane.background
set(value) {
backgroundOverride = value
if (opaqueState) pane.background = value
markDirty()
}
override var linkColor: Color
get() = linkColorOverride ?: Color(0x58, 0x9D, 0xF6)
set(value) { linkColorOverride = value; markDirty() }
override var codeBg: Color
get() = codeBgOverride ?: Color(0x3C, 0x3F, 0x41)
set(value) { codeBgOverride = value; markDirty() }
override var preBg: Color
get() = preBgOverride ?: Color(0x2B, 0x2B, 0x2B)
set(value) { preBgOverride = value; markDirty() }
override var preFg: Color
get() = preFgOverride ?: Color(0xA9, 0xB7, 0xC6)
set(value) { preFgOverride = value; markDirty() }
override var codeFont: String
// _EditorFontNoLigatures_ is resolved by EditorCssFontResolver to the global editor font
get() = codeFontOverride ?: "_EditorFontNoLigatures_"
set(value) { codeFontOverride = value; markDirty() }
override var quoteBorder: Color
get() = quoteBorderOverride ?: Color(0x55, 0x55, 0x55)
set(value) { quoteBorderOverride = value; markDirty() }
override var quoteFg: Color
get() = quoteFgOverride ?: Color(0x99, 0x99, 0x99)
set(value) { quoteFgOverride = value; markDirty() }
override var tableBorder: Color
get() = tableBorderOverride ?: Color(0x55, 0x55, 0x55)
set(value) { tableBorderOverride = value; markDirty() }
override var opaque: Boolean
get() = opaqueState
set(value) {
opaqueState = value
pane.isOpaque = value
if (value) pane.background = backgroundOverride ?: UIUtil.getPanelBackground()
markDirty()
}
override fun resetStyles() {
fontOverride = null
foregroundOverride = null
backgroundOverride = null
linkColorOverride = null
codeBgOverride = null
preBgOverride = null
preFgOverride = null
codeFontOverride = null
quoteBorderOverride = null
quoteFgOverride = null
tableBorderOverride = null
opaqueState = true
pane.isOpaque = true
pane.background = UIUtil.getPanelBackground()
markDirty()
}
// -- content API ---------------------------------------------------
override fun set(text: String) {
source.clear()
source.append(text)
render()
}
override fun append(delta: String) {
source.append(delta)
render()
}
override fun clear() {
source.clear()
rendered = ""
pane.text = ""
}
override fun addLinkListener(listener: LinkListener) { listeners.add(listener) }
override fun removeLinkListener(listener: LinkListener) { listeners.remove(listener) }
override fun markdown(): String = source.toString()
override fun html(): String = rendered
override fun overrideSheet(): String = buildOverrideRulesString()
override fun simulateLink(href: String) {
val event = LinkEvent(href)
for (l in listeners) l.onLink(event)
}
private fun markDirty() {
pane.reloadCssStylesheets()
if (source.isNotEmpty()) render()
}
private fun render() {
val body = renderer.render(parser.parse(source.toString()))
rendered = body
pane.text = "<html><body>$body</body></html>"
pane.caretPosition = 0
}
private fun buildOverrideStyleSheet(): StyleSheet {
val sheet = StyleSheet()
val rules = buildOverrideRulesString()
if (rules.isNotEmpty()) {
try { sheet.addRule(rules) } catch (_: Exception) {}
}
return sheet
}
private fun buildOverrideRulesString(): String {
val rules = StringBuilder()
val body = mutableListOf<String>()
foregroundOverride?.let { body.add("color: ${hex(it)}") }
if (!opaqueState) body.add("background: transparent")
fontOverride?.let {
body.add("font-family: '${it.family}', sans-serif")
body.add("font-size: ${it.size}pt")
}
if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ")
linkColorOverride?.let { rules.append("a { color: ${hex(it)} } ") }
codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${it}', monospace } ") }
preBgOverride?.let { rules.append("pre { background: ${hex(it)} } ") }
preFgOverride?.let { rules.append("pre { color: ${hex(it)} } ") }
codeBgOverride?.let { rules.append("code { background: ${hex(it)} } ") }
quoteBorderOverride?.let { rules.append("blockquote { border-left-color: ${hex(it)} } ") }
quoteFgOverride?.let { rules.append("blockquote { color: ${hex(it)} } ") }
tableBorderOverride?.let { rules.append("th, td { border-color: ${hex(it)} } ") }
return rules.toString().trim()
}
companion object {
private fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue)
}
}
}
@@ -0,0 +1,26 @@
package ai.kilocode.client.session
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
class AppWatchingTest : SessionControllerTestBase() {
fun `test app state change fires AppChanged`() {
val m = controller()
val events = collect(m)
flush()
events.clear()
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
flush()
assertControllerEvents("AppChanged", events)
assertSession(
"""
[app: READY] [workspace: PENDING]
""",
m,
show = false,
)
}
}
@@ -0,0 +1,54 @@
package ai.kilocode.client.session
class ConfigSelectionTest : SessionControllerTestBase() {
fun `test selectModel updates SessionModel and calls updateConfig`() {
val m = controller()
collect(m)
flush()
edt { m.selectModel("kilo", "gpt-5") }
flush()
assertEquals(1, rpc.configs.size)
assertEquals("kilo/gpt-5", rpc.configs[0].second.model)
assertSession(
"""
[kilo/gpt-5] [app: DISCONNECTED] [workspace: PENDING]
""",
m,
show = false,
)
}
fun `test selectAgent updates SessionModel and calls updateConfig`() {
val m = controller()
collect(m)
flush()
edt { m.selectAgent("plan") }
flush()
assertEquals(1, rpc.configs.size)
assertEquals("plan", rpc.configs[0].second.agent)
assertSession(
"""
[plan] [app: DISCONNECTED] [workspace: PENDING]
""",
m,
show = false,
)
}
fun `test selectModel fires WorkspaceReady event`() {
val m = controller()
val events = collect(m)
flush()
events.clear()
edt { m.selectModel("kilo", "gpt-5") }
flush()
assertControllerEvents("WorkspaceReady", events)
}
}
@@ -0,0 +1,43 @@
package ai.kilocode.client.session
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.rpc.dto.MessageWithPartsDto
class HistoryLoadingTest : SessionControllerTestBase() {
fun `test existing session loads history on init`() {
val m = msg("msg1", "ses_test", "user")
val part = part("prt1", "ses_test", "msg1", "text", text = "hello")
rpc.history.add(MessageWithPartsDto(m, listOf(part)))
val c = controller("ses_test")
val modelEvents = collectModelEvents(c)
flush()
assertModelEvents("HistoryLoaded", modelEvents)
assertModel(
"""
user#msg1
text#prt1:
hello
""",
c,
)
}
fun `test non-empty history shows messages view`() {
rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList()))
val c = controller("ses_test")
flush()
assertSession(
"""
user#msg1
[app: DISCONNECTED] [workspace: PENDING]
""",
c,
)
}
}
@@ -0,0 +1,129 @@
package ai.kilocode.client.session
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.SessionStatusDto
import com.intellij.openapi.util.Disposer
class ListenerLifecycleTest : SessionControllerTestBase() {
fun `test listener removed on parent dispose`() {
val m = controller()
val disposable = Disposer.newDisposable("listener-parent")
Disposer.register(parent, disposable)
val events = mutableListOf<SessionControllerEvent>()
m.addListener(disposable) { events.add(it) }
edt { m.prompt("before") }
flush()
Disposer.dispose(disposable)
edt { m.prompt("after") }
flush()
assertControllerEvents("""
ViewChanged show
AppChanged
WorkspaceChanged
""", events)
}
fun `test all listeners notified`() {
val m = controller()
val events1 = mutableListOf<SessionControllerEvent>()
val events2 = mutableListOf<SessionControllerEvent>()
val d1 = Disposer.newDisposable("l1")
val d2 = Disposer.newDisposable("l2")
Disposer.register(parent, d1)
Disposer.register(parent, d2)
m.addListener(d1) { events1.add(it) }
m.addListener(d2) { events2.add(it) }
edt { m.prompt("go") }
flush()
assertControllerEvents("""
ViewChanged show
AppChanged
WorkspaceChanged
""", events1)
assertControllerEvents("""
ViewChanged show
AppChanged
WorkspaceChanged
""", events2)
}
fun `test session status idle fires StateChanged to Idle`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
modelEvents.clear()
emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle")))
assertModelEvents("StateChanged Idle", modelEvents)
assertEquals(SessionState.Idle, m.model.state)
}
fun `test session status busy fires StateChanged to Busy`() {
val (_, _, modelEvents) = prompted()
emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy", null)))
assertModelEvents("StateChanged Busy", modelEvents)
}
fun `test session status busy ignored when already busy`() {
val (_, _, modelEvents) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
modelEvents.clear()
emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy")))
// Already in Busy — status busy is ignored
assertTrue(modelEvents.isEmpty())
}
fun `test session status retry with zero attempt`() {
val (m, _, _) = prompted()
emit(ChatEventDto.SessionStatusChanged(
"ses_test",
SessionStatusDto("retry", "Waiting...", attempt = 0, next = 1000L),
))
val state = m.model.state as SessionState.Retry
assertEquals("Waiting...", state.message)
assertEquals(0, state.attempt)
assertEquals(1000L, state.next)
}
fun `test session status offline`() {
val (m, _, _) = prompted()
emit(ChatEventDto.SessionStatusChanged(
"ses_test",
SessionStatusDto("offline", "Disconnected", requestID = "req_1"),
))
val state = m.model.state as SessionState.Offline
assertEquals("Disconnected", state.message)
assertEquals("req_1", state.requestId)
}
fun `test session status unknown type is ignored`() {
val (m, _, modelEvents) = prompted()
modelEvents.clear()
emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("weird_future_status")))
assertTrue(modelEvents.isEmpty())
assertEquals(SessionState.Idle, m.model.state)
}
}
@@ -0,0 +1,116 @@
package ai.kilocode.client.session
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.MessageTimeDto
class MessageListTest : SessionControllerTestBase() {
fun `test MessageUpdated adds message to SessionModel`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
assertModelEvents("MessageAdded msg1", modelEvents)
assertNotNull(m.model.message("msg1"))
}
fun `test MessageUpdated second event updates existing message`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
modelEvents.clear()
// Second update for the same message ID (e.g. tokens/cost finalized)
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
// Should fire MessageUpdated, not MessageAdded
assertModelEvents("MessageUpdated msg1", modelEvents)
assertNotNull(m.model.message("msg1"))
}
fun `test MessageUpdated second event preserves existing parts`() {
val (m, _, _) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello")))
// Update the message info (should not wipe parts)
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
assertNotNull(m.model.message("msg1")!!.parts["prt1"])
}
fun `test PartUpdated text updates SessionModel`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello")))
assertModelEvents("""
MessageAdded msg1
ContentAdded msg1/prt1
""", modelEvents)
assertModel(
"""
assistant#msg1
text#prt1:
hello
""",
m,
)
}
fun `test PartDelta appends text to SessionModel`() {
val (m, _, _) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "hello "), flush = false)
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world"))
assertModel(
"""
assistant#msg1
text#prt1:
hello world
""",
m,
)
}
fun `test PartRemoved removes content from message`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello")))
modelEvents.clear()
emit(ChatEventDto.PartRemoved("ses_test", "msg1", "prt1"))
assertNull(m.model.message("msg1")!!.parts["prt1"])
assertModelEvents("ContentRemoved msg1/prt1", modelEvents)
}
fun `test PartRemoved unknown part is noop`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
modelEvents.clear()
emit(ChatEventDto.PartRemoved("ses_test", "msg1", "no_such_part"))
assertTrue(modelEvents.isEmpty())
}
fun `test MessageRemoved removes from SessionModel`() {
val (m, _, _) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "user")))
assertNotNull(m.model.message("msg1"))
emit(ChatEventDto.MessageRemoved("ses_test", "msg1"))
assertNull(m.model.message("msg1"))
}
}
@@ -0,0 +1,202 @@
package ai.kilocode.client.session
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.QuestionInfoDto
import ai.kilocode.rpc.dto.QuestionOptionDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.ToolRefDto
class PromptLifecycleTest : SessionControllerTestBase() {
fun `test PermissionAsked moves state to AwaitingPermission`() {
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
assertSession(
"""
permission#perm1
tool: msg1/call1
name: edit
patterns: *.kt
always: <none>
file: src/A.kt
state: RESPONDING
metadata: kind=edit
[code] [kilo/gpt-5] [awaiting-permission]
""",
m,
)
}
fun `test PermissionReplied resumes Busy state`() {
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
emit(ChatEventDto.PermissionReplied("ses_test", "perm1"))
assertSession(
"""
[code] [kilo/gpt-5] [busy] [considering next steps]
""",
m,
)
}
fun `test QuestionAsked moves state to AwaitingQuestion`() {
val (m, _, _) = prompted()
emit(ChatEventDto.QuestionAsked("ses_test", question("q1")))
assertSession(
"""
question#q1
tool: msg1/call1
header: Choice
prompt: Pick one
option: A - Option A
multiple: false
custom: true
[code] [kilo/gpt-5] [awaiting-question]
""",
m,
)
}
fun `test QuestionReplied resumes Busy state`() {
val (m, _, _) = prompted()
emit(ChatEventDto.QuestionAsked("ses_test", question("q1")))
emit(ChatEventDto.QuestionReplied("ses_test", "q1"))
assertSession(
"""
[code] [kilo/gpt-5] [busy] [considering next steps]
""",
m,
)
}
fun `test QuestionRejected moves state to Idle`() {
val (m, _, _) = prompted()
emit(ChatEventDto.QuestionAsked("ses_test", question("q1")))
emit(ChatEventDto.QuestionRejected("ses_test", "q1"))
assertSession(
"""
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
fun `test PermissionReplied with wrong requestID is ignored`() {
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
emit(ChatEventDto.PermissionReplied("ses_test", "wrong_id"))
// State must remain AwaitingPermission
assertTrue(m.model.state is SessionState.AwaitingPermission)
}
fun `test QuestionReplied with wrong requestID is ignored`() {
val (m, _, _) = prompted()
emit(ChatEventDto.QuestionAsked("ses_test", question("q1")))
emit(ChatEventDto.QuestionReplied("ses_test", "wrong_id"))
// State must remain AwaitingQuestion
assertTrue(m.model.state is SessionState.AwaitingQuestion)
}
fun `test QuestionRejected with wrong requestID is ignored`() {
val (m, _, _) = prompted()
emit(ChatEventDto.QuestionAsked("ses_test", question("q1")))
emit(ChatEventDto.QuestionRejected("ses_test", "wrong_id"))
// State must remain AwaitingQuestion
assertTrue(m.model.state is SessionState.AwaitingQuestion)
}
fun `test replyPermission calls RPC`() {
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
edt { m.replyPermission("perm1", PermissionReplyDto("once")) }
flush()
assertEquals(1, rpc.permissionReplies.size)
assertEquals("perm1", rpc.permissionReplies[0].first)
assertEquals("once", rpc.permissionReplies[0].third.reply)
}
fun `test replyPermission with rules saves always rules first`() {
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
val rules = PermissionAlwaysRulesDto(approvedAlways = listOf("src/**"))
edt { m.replyPermission("perm1", PermissionReplyDto("always"), rules) }
flush()
assertEquals(1, rpc.permissionRulesSaved.size)
assertEquals("perm1", rpc.permissionRulesSaved[0].first)
assertEquals(1, rpc.permissionReplies.size)
}
fun `test replyQuestion calls RPC`() {
val (m, _, _) = prompted()
emit(ChatEventDto.QuestionAsked("ses_test", question("q1")))
edt { m.replyQuestion("q1", QuestionReplyDto(listOf(listOf("A")))) }
flush()
assertEquals(1, rpc.questionReplies.size)
assertEquals("q1", rpc.questionReplies[0].first)
}
fun `test rejectQuestion calls RPC`() {
val (m, _, _) = prompted()
emit(ChatEventDto.QuestionAsked("ses_test", question("q1")))
edt { m.rejectQuestion("q1") }
flush()
assertEquals(1, rpc.questionRejects.size)
assertEquals("q1", rpc.questionRejects[0].first)
}
private fun permission(id: String) = PermissionRequestDto(
id = id,
sessionID = "ses_test",
permission = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
metadata = mapOf("kind" to "edit", "file" to "src/A.kt", "state" to "RESPONDING"),
tool = ToolRefDto("msg1", "call1"),
)
private fun question(id: String) = QuestionRequestDto(
id = id,
sessionID = "ses_test",
questions = listOf(
QuestionInfoDto(
question = "Pick one",
header = "Choice",
options = listOf(QuestionOptionDto("A", "Option A")),
multiple = false,
custom = true,
),
),
tool = ToolRefDto("msg1", "call1"),
)
}
@@ -0,0 +1,184 @@
package ai.kilocode.client.session
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.TodoDto
/**
* Controller-level tests for session artifact events:
* [ChatEventDto.SessionDiffChanged], [ChatEventDto.TodoUpdated], and
* [ChatEventDto.SessionCompacted].
*
* Verifies that the controller forwards them to the model and fires the
* expected model events, and that the session ID filter drops events
* belonging to other sessions.
*/
class SessionArtifactsTest : SessionControllerTestBase() {
// ------ SessionDiffChanged ------
fun `test SessionDiffChanged updates model and fires DiffUpdated`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.SessionDiffChanged(
sessionID = "ses_test",
diff = listOf(
DiffFileDto("src/A.kt", additions = 5, deletions = 2),
DiffFileDto("src/B.kt", additions = 1, deletions = 0),
),
))
assertModelEvents("DiffUpdated files=2", modelEvents)
assertSession(
"""
diff: src/A.kt src/B.kt
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
fun `test SessionDiffChanged from other session is ignored`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.SessionDiffChanged(
sessionID = "ses_other",
diff = listOf(DiffFileDto("src/Other.kt", additions = 1, deletions = 0)),
))
assertModelEvents("", modelEvents)
assertSession(
"""
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
fun `test SessionDiffChanged with empty list clears diff`() {
val (m, _, _) = prompted()
emit(ChatEventDto.SessionDiffChanged("ses_test", listOf(DiffFileDto("src/A.kt", 3, 1))))
emit(ChatEventDto.SessionDiffChanged("ses_test", emptyList()))
assertSession(
"""
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
// ------ TodoUpdated ------
fun `test TodoUpdated updates model and fires TodosUpdated`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.TodoUpdated(
sessionID = "ses_test",
todos = listOf(
TodoDto("Write tests", "pending", "high"),
TodoDto("Ship it", "in_progress", "medium"),
),
))
assertModelEvents("TodosUpdated count=2", modelEvents)
assertSession(
"""
todo: [pending] Write tests
todo: [in_progress] Ship it
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
fun `test TodoUpdated from other session is ignored`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.TodoUpdated(
sessionID = "ses_other",
todos = listOf(TodoDto("alien task", "pending", "low")),
))
assertModelEvents("", modelEvents)
assertSession(
"""
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
fun `test TodoUpdated replaces previous todo list`() {
val (m, _, _) = prompted()
emit(ChatEventDto.TodoUpdated("ses_test", listOf(
TodoDto("task1", "pending", "high"),
TodoDto("task2", "pending", "low"),
)))
emit(ChatEventDto.TodoUpdated("ses_test", listOf(
TodoDto("only task", "completed", "medium"),
)))
assertSession(
"""
todo: [completed] only task
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
// ------ SessionCompacted ------
fun `test SessionCompacted fires Compacted event`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.SessionCompacted("ses_test"))
assertModelEvents("Compacted count=1", modelEvents)
assertSession(
"""
compacted: 1
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
fun `test multiple SessionCompacted events accumulate`() {
val (m, _, _) = prompted()
emit(ChatEventDto.SessionCompacted("ses_test"), flush = false)
emit(ChatEventDto.SessionCompacted("ses_test"), flush = false)
emit(ChatEventDto.SessionCompacted("ses_test"))
assertSession(
"""
compacted: 3
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
fun `test SessionCompacted from other session is ignored`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.SessionCompacted("ses_other"))
assertModelEvents("", modelEvents)
assertSession(
"""
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
}
@@ -1,7 +1,9 @@
package ai.kilocode.client.session.model
package ai.kilocode.client.session
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.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
@@ -10,6 +12,8 @@ import ai.kilocode.client.app.Workspace
import ai.kilocode.rpc.dto.AgentDto
import ai.kilocode.rpc.dto.AgentsDto
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.MessageDto
@@ -30,12 +34,12 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
/**
* Base class for [SessionModel] tests.
* Base class for [SessionController] tests.
*
* Provides real IntelliJ Application/EDT/Disposer via [BasePlatformTestCase],
* real frontend services wired to fake RPC backends, and shared helpers.
*/
abstract class SessionModelTestBase : BasePlatformTestCase() {
abstract class SessionControllerTestBase : BasePlatformTestCase() {
protected lateinit var rpc: FakeSessionRpcApi
protected lateinit var appRpc: FakeAppRpcApi
@@ -73,16 +77,16 @@ abstract class SessionModelTestBase : BasePlatformTestCase() {
}
}
// ------ Model creation ------
// ------ Controller creation ------
protected fun model(id: String? = null) =
SessionModel(parent, id, sessions, workspace, app, scope)
protected fun controller(id: String? = null) =
SessionController(parent, id, sessions, workspace, app, scope)
// ------ Event collection ------
/** Attach a listener that collects events and asserts EDT. */
protected fun collect(m: SessionModel): MutableList<SessionEvent> {
val events = mutableListOf<SessionEvent>()
/** Attach a listener that collects lifecycle events and asserts EDT. */
protected fun collect(m: SessionController): MutableList<SessionControllerEvent> {
val events = mutableListOf<SessionControllerEvent>()
val disposable = Disposer.newDisposable("listener")
Disposer.register(parent, disposable)
m.addListener(disposable) { event ->
@@ -92,6 +96,17 @@ abstract class SessionModelTestBase : BasePlatformTestCase() {
return events
}
/** Attach a listener that collects model events (messages, parts, phase). */
protected fun collectModelEvents(m: SessionController): MutableList<SessionModelEvent> {
val events = mutableListOf<SessionModelEvent>()
val disposable = Disposer.newDisposable("model-listener")
Disposer.register(parent, disposable)
m.model.addListener(disposable) { event ->
events.add(event)
}
return events
}
// ------ EDT + coroutine helpers ------
/** Let coroutines settle, then drain all pending EDT events. */
@@ -107,17 +122,43 @@ abstract class SessionModelTestBase : BasePlatformTestCase() {
}
/** Emit a chat event into the fake RPC flow. */
protected fun emit(event: ChatEventDto) = runBlocking {
rpc.events.emit(event)
protected fun emit(event: ChatEventDto, flush: Boolean = true) {
runBlocking { rpc.events.emit(event) }
if (flush) flush()
}
/** Create a model, attach listener, send initial prompt, and flush. */
protected fun prompted(): Pair<SessionModel, MutableList<SessionEvent>> {
val m = model()
/** Create a controller, attach both listeners, send initial prompt, and flush. */
protected fun prompted(): Triple<SessionController, MutableList<SessionControllerEvent>, MutableList<SessionModelEvent>> {
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller()
val events = collect(m)
val modelEvents = collectModelEvents(m)
flush()
edt { m.prompt("go") }
flush()
return m to events
return Triple(m, events, modelEvents)
}
protected fun assertModel(expected: String, model: SessionModel) {
assertEquals(expected.trimIndent().trim(), model.toString().trim())
}
protected fun assertModel(expected: String, c: SessionController) {
assertModel(expected, c.model)
}
protected fun assertSession(expected: String, c: SessionController, show: Boolean = true) {
assertEquals(expected.trimIndent().trim(), c.toString().trim())
assertEquals(show, c.model.showMessages)
}
protected fun assertControllerEvents(expected: String, events: List<SessionControllerEvent>) {
assertEquals(expected.trimIndent().trim(), events.joinToString("\n"))
}
protected fun assertModelEvents(expected: String, events: List<SessionModelEvent>) {
assertEquals(expected.trimIndent().trim(), events.joinToString("\n"))
}
// ------ DTO factories ------
@@ -1,10 +1,12 @@
package ai.kilocode.client.session.model
package ai.kilocode.client.session
class SessionCreationTest : SessionModelTestBase() {
class SessionCreationTest : SessionControllerTestBase() {
fun `test prompt creates session on first call`() {
val m = model()
val m = controller()
val events = collect(m)
flush()
events.clear()
edt { m.prompt("hello") }
flush()
@@ -12,11 +14,17 @@ class SessionCreationTest : SessionModelTestBase() {
assertEquals(1, rpc.creates)
assertEquals(1, rpc.prompts.size)
assertEquals("ses_test", rpc.prompts[0].first)
assertTrue(events.any { it is SessionEvent.ViewChanged && it.show })
assertControllerEvents("ViewChanged show", events)
assertSession(
"""
[app: DISCONNECTED] [workspace: PENDING]
""",
m,
)
}
fun `test prompt reuses existing session`() {
val m = model()
val m = controller()
edt { m.prompt("first") }
flush()
@@ -29,7 +37,7 @@ class SessionCreationTest : SessionModelTestBase() {
}
fun `test prompt with existing ID skips creation`() {
val m = model("existing")
val m = controller("existing")
collect(m)
flush()
@@ -0,0 +1,266 @@
package ai.kilocode.client.session
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.QuestionInfoDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionStatusDto
import ai.kilocode.rpc.dto.SessionTimeDto
/**
* Tests for pending permission/question recovery after history load.
*
* VS Code rehydrates pending prompts by calling list endpoints after
* reconnect. JetBrains now does the same in [SessionController.recoverPending].
*/
class SessionRecoveryTest : SessionControllerTestBase() {
override fun setUp() {
super.setUp()
// Set a pre-existing session in the fake API
rpc.session = rpc.session.copy(id = "ses_test")
}
fun `test pending permission is recovered on history load`() {
rpc.pendingPermissionList.add(
PermissionRequestDto(
id = "perm_pending",
sessionID = "ses_test",
permission = "read",
patterns = listOf("*.json"),
)
)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertTrue(m.model.state is SessionState.AwaitingPermission)
val perm = (m.model.state as SessionState.AwaitingPermission).permission
assertEquals("perm_pending", perm.id)
assertEquals("read", perm.name)
}
fun `test pending question is recovered when no pending permissions`() {
rpc.pendingQuestionList.add(
QuestionRequestDto(
id = "q_pending",
sessionID = "ses_test",
questions = listOf(QuestionInfoDto("What?", "Q")),
)
)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertTrue(m.model.state is SessionState.AwaitingQuestion)
val q = (m.model.state as SessionState.AwaitingQuestion).question
assertEquals("q_pending", q.id)
}
fun `test pending from other session is ignored`() {
rpc.pendingPermissionList.add(
PermissionRequestDto(
id = "perm_other",
sessionID = "ses_other", // different session
permission = "read",
patterns = emptyList(),
)
)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
// State should remain Idle — other session's pending is irrelevant
assertEquals(SessionState.Idle, m.model.state)
}
fun `test permission takes priority over question in recovery`() {
rpc.pendingPermissionList.add(
PermissionRequestDto(
id = "perm_pending",
sessionID = "ses_test",
permission = "edit",
patterns = emptyList(),
)
)
rpc.pendingQuestionList.add(
QuestionRequestDto(
id = "q_pending",
sessionID = "ses_test",
questions = listOf(QuestionInfoDto("What?", "Q")),
)
)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
// Permission list non-empty → AwaitingPermission wins
assertTrue(m.model.state is SessionState.AwaitingPermission)
}
// ------ Status seeding from KiloSessionService.statuses ------
fun `test busy status is seeded from statuses map`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy"))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertSession(
"""
[code] [kilo/gpt-5] [busy] [considering next steps]
""",
m, show = false,
)
}
fun `test retry status is seeded with message attempt and next`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto(
type = "retry",
message = "Rate limited",
attempt = 3,
next = 5000L,
))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertSession(
"""
[code] [kilo/gpt-5] [retry] [Rate limited]
""",
m, show = false,
)
val state = m.model.state as SessionState.Retry
assertEquals(3, state.attempt)
assertEquals(5000L, state.next)
}
fun `test offline status is seeded with message and requestId`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto(
type = "offline",
message = "No network",
requestID = "req_xyz",
))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertSession(
"""
[code] [kilo/gpt-5] [offline] [No network]
""",
m, show = false,
)
assertEquals("req_xyz", (m.model.state as SessionState.Offline).requestId)
}
fun `test idle status in map leaves controller in Idle`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("idle"))
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertSession(
"""
[code] [kilo/gpt-5] [idle]
""",
m, show = false,
)
}
fun `test missing status entry leaves controller in Idle`() {
rpc.statuses.value = emptyMap()
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertSession(
"""
[code] [kilo/gpt-5] [idle]
""",
m, show = false,
)
}
fun `test pending permission overrides a seeded busy status`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy"))
rpc.pendingPermissionList.add(
PermissionRequestDto(
id = "perm_p",
sessionID = "ses_test",
permission = "read",
patterns = listOf("*.kt"),
)
)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertSession(
"""
permission#perm_p
tool: <none>
name: read
patterns: *.kt
always: <none>
file: <none>
state: PENDING
metadata: <none>
[code] [kilo/gpt-5] [awaiting-permission]
""",
m, show = false,
)
}
fun `test pending question overrides a seeded retry status`() {
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("retry", "Rate limited", attempt = 1, next = 1000L))
rpc.pendingQuestionList.add(
QuestionRequestDto(
id = "q_p",
sessionID = "ses_test",
questions = listOf(QuestionInfoDto("Proceed?", "Q")),
)
)
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertSession(
"""
question#q_p
tool: <none>
header: Q
prompt: Proceed?
multiple: false
custom: true
[code] [kilo/gpt-5] [awaiting-question]
""",
m, show = false,
)
}
}
@@ -0,0 +1,43 @@
package ai.kilocode.client.session
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.rpc.dto.ChatEventDto
class StatusComputationTest : SessionControllerTestBase() {
fun `test status shows tool-specific text`() {
val (m, _, _) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash")))
assertSession(
"""
assistant#msg1
tool#prt1 bash [PENDING]
[code] [kilo/gpt-5] [busy] [running commands]
""",
m,
)
}
fun `test PartUpdated after TurnClose does not fire StateChanged`() {
val (_, _, modelEvents) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
emit(ChatEventDto.TurnOpen("ses_test"))
emit(ChatEventDto.TurnClose("ses_test", "completed"))
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "late")))
assertModelEvents("""
MessageAdded msg1
StateChanged Busy
StateChanged Idle
ContentAdded msg1/prt1
""", modelEvents)
}
}
@@ -0,0 +1,164 @@
package ai.kilocode.client.session
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.MessageErrorDto
import ai.kilocode.rpc.dto.SessionStatusDto
class TurnLifecycleTest : SessionControllerTestBase() {
fun `test TurnOpen fires StateChanged to Busy`() {
val (m, _, _) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
assertSession(
"""
[code] [kilo/gpt-5] [busy] [considering next steps]
""",
m,
)
}
fun `test TurnClose fires StateChanged to Idle`() {
val (m, _, _) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
emit(ChatEventDto.TurnClose("ses_test", "completed"))
assertSession(
"""
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
fun `test TurnClose error reason does not clobber Error state`() {
val (m, _, _) = prompted()
// Error event arrives just before TurnClose
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = "Timed out")))
emit(ChatEventDto.TurnClose("ses_test", "error"))
// Error state must survive
assertSession(
"""
[code] [kilo/gpt-5] [error] [Timed out]
""",
m,
)
}
fun `test TurnClose completed clobbers Error state`() {
val (m, _, _) = prompted()
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = "Timed out")))
emit(ChatEventDto.TurnClose("ses_test", "completed"))
// "completed" always wins over error
assertSession(
"""
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
fun `test Error fires StateChanged to Error`() {
val (m, _, _) = prompted()
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Bad Request")))
assertSession(
"""
[code] [kilo/gpt-5] [error] [Bad Request]
""",
m,
)
}
fun `test Error with null message falls back to type`() {
val (m, _, _) = prompted()
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = null)))
assertSession(
"""
[code] [kilo/gpt-5] [error] [timeout]
""",
m,
)
}
fun `test SessionStatusChanged retry with full detail`() {
val (m, _, _) = prompted()
emit(ChatEventDto.SessionStatusChanged(
"ses_test",
SessionStatusDto("retry", "Rate limited", attempt = 2, next = 5000L),
))
val state = m.model.state as SessionState.Retry
assertEquals("Rate limited", state.message)
assertEquals(2, state.attempt)
assertEquals(5000L, state.next)
}
fun `test SessionStatusChanged offline with requestID`() {
val (m, _, _) = prompted()
emit(ChatEventDto.SessionStatusChanged(
"ses_test",
SessionStatusDto("offline", "No network", requestID = "req_abc"),
))
val state = m.model.state as SessionState.Offline
assertEquals("No network", state.message)
assertEquals("req_abc", state.requestId)
}
fun `test SessionIdle transitions to Idle from Busy`() {
val (m, _, _) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
emit(ChatEventDto.SessionIdle("ses_test"))
assertSession(
"""
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
fun `test SessionIdle does not clobber Error state`() {
val (m, _, _) = prompted()
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = "Timed out")))
emit(ChatEventDto.SessionIdle("ses_test"))
// Error state must survive
assertSession(
"""
[code] [kilo/gpt-5] [error] [Timed out]
""",
m,
)
}
fun `test events for wrong session are ignored`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.TurnOpen("ses_other"))
// No state change — event was filtered out
assertSession(
"""
[code] [kilo/gpt-5] [idle]
""",
m,
)
assertModelEvents("", modelEvents)
}
}
@@ -0,0 +1,36 @@
package ai.kilocode.client.session
class ViewSwitchingTest : SessionControllerTestBase() {
fun `test first prompt shows messages view`() {
val m = controller()
val events = collect(m)
flush()
events.clear()
edt { m.prompt("hello") }
flush()
assertControllerEvents("ViewChanged show", events)
assertSession(
"""
[app: DISCONNECTED] [workspace: PENDING]
""",
m,
)
}
fun `test ViewChanged not fired twice`() {
val m = controller()
val events = collect(m)
flush()
events.clear()
edt { m.prompt("first") }
flush()
edt { m.prompt("second") }
flush()
assertControllerEvents("ViewChanged show", events)
}
}
@@ -0,0 +1,43 @@
package ai.kilocode.client.session
class WorkspaceWatchingTest : SessionControllerTestBase() {
fun `test workspace ready populates agents and models`() {
val m = controller()
val events = collect(m)
flush()
events.clear()
projectRpc.state.value = workspaceReady()
flush()
assertEquals(1, m.model.agents.size)
assertEquals("code", m.model.agents[0].name)
assertEquals(1, m.model.models.size)
assertEquals("gpt-5", m.model.models[0].id)
assertFalse(m.model.isReady())
assertControllerEvents("""
WorkspaceChanged
WorkspaceReady
""", events)
assertSession(
"""
[code] [kilo/gpt-5] [app: DISCONNECTED] [workspace: READY]
""",
m,
show = false,
)
}
fun `test workspace ready sets default agent and model`() {
val m = controller()
collect(m)
flush()
projectRpc.state.value = workspaceReady()
flush()
assertEquals("code", m.model.agent)
assertEquals("kilo/gpt-5", m.model.model)
}
}
@@ -1,19 +0,0 @@
package ai.kilocode.client.session.model
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
class AppWatchingTest : SessionModelTestBase() {
fun `test app state change fires AppChanged`() {
val m = model()
val events = collect(m)
flush()
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
flush()
assertTrue(events.any { it is SessionEvent.AppChanged })
assertEquals(KiloAppStatusDto.READY, m.chat.app.status)
}
}
@@ -1,41 +0,0 @@
package ai.kilocode.client.session.model
class ConfigSelectionTest : SessionModelTestBase() {
fun `test selectModel updates ChatModel and calls updateConfig`() {
val m = model()
collect(m)
flush()
edt { m.selectModel("kilo", "gpt-5") }
flush()
assertEquals("kilo/gpt-5", m.chat.model)
assertEquals(1, rpc.configs.size)
assertEquals("kilo/gpt-5", rpc.configs[0].second.model)
}
fun `test selectAgent updates ChatModel and calls updateConfig`() {
val m = model()
collect(m)
flush()
edt { m.selectAgent("plan") }
flush()
assertEquals("plan", m.chat.agent)
assertEquals(1, rpc.configs.size)
assertEquals("plan", rpc.configs[0].second.agent)
}
fun `test selectModel fires WorkspaceReady event`() {
val m = model()
val events = collect(m)
flush()
edt { m.selectModel("kilo", "gpt-5") }
flush()
assertTrue(events.any { it is SessionEvent.WorkspaceReady })
}
}
@@ -1,31 +0,0 @@
package ai.kilocode.client.session.model
import ai.kilocode.rpc.dto.MessageWithPartsDto
class HistoryLoadingTest : SessionModelTestBase() {
fun `test existing session loads history on init`() {
val m = msg("msg1", "ses_test", "user")
val p = part("prt1", "ses_test", "msg1", "text", text = "hello")
rpc.history.add(MessageWithPartsDto(m, listOf(p)))
val model = model("ses_test")
val events = collect(model)
flush()
assertTrue(events.any { it is SessionEvent.HistoryLoaded })
assertNotNull(model.chat.message("msg1"))
assertEquals("hello", model.chat.part("msg1", "prt1")?.text?.toString())
}
fun `test non-empty history shows messages view`() {
rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList()))
val model = model("ses_test")
val events = collect(model)
flush()
assertTrue(events.any { it is SessionEvent.ViewChanged && it.show })
assertTrue(model.chat.showMessages)
}
}
@@ -1,60 +0,0 @@
package ai.kilocode.client.session.model
import ai.kilocode.rpc.dto.SessionStatusDto
import com.intellij.openapi.util.Disposer
class ListenerLifecycleTest : SessionModelTestBase() {
fun `test listener removed on parent dispose`() {
val m = model()
val disposable = Disposer.newDisposable("listener-parent")
Disposer.register(parent, disposable)
val events = mutableListOf<SessionEvent>()
m.addListener(disposable) { events.add(it) }
edt { m.prompt("before") }
flush()
val before = events.size
Disposer.dispose(disposable)
edt { m.prompt("after") }
flush()
assertEquals(before, events.size)
}
fun `test all listeners notified`() {
val m = model()
val events1 = mutableListOf<SessionEvent>()
val events2 = mutableListOf<SessionEvent>()
val d1 = Disposer.newDisposable("l1")
val d2 = Disposer.newDisposable("l2")
Disposer.register(parent, d1)
Disposer.register(parent, d2)
m.addListener(d1) { events1.add(it) }
m.addListener(d2) { events2.add(it) }
edt { m.prompt("go") }
flush()
assertTrue(events1.isNotEmpty())
assertTrue(events2.isNotEmpty())
assertEquals(events1.map { it::class }, events2.map { it::class })
}
fun `test session status busy fires BusyChanged`() {
val m = model()
val events = collect(m)
edt { m.prompt("go") }
flush()
rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy", null))
flush()
assertTrue(events.any { it is SessionEvent.BusyChanged && it.busy })
}
}
@@ -1,55 +0,0 @@
package ai.kilocode.client.session.model
import ai.kilocode.rpc.dto.ChatEventDto
class MessageListTest : SessionModelTestBase() {
fun `test MessageUpdated adds message to ChatModel`() {
val (m, events) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
flush()
assertTrue(events.any { it is SessionEvent.MessageAdded && it.id == "msg1" })
assertNotNull(m.chat.message("msg1"))
}
fun `test PartUpdated text fires PartUpdated event`() {
val (m, events) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
flush()
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "hello")))
flush()
assertTrue(events.any { it is SessionEvent.PartUpdated && it.messageId == "msg1" && it.partId == "prt1" })
}
fun `test PartDelta appends text to ChatModel`() {
val (m, _) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
flush()
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "hello "))
emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world"))
flush()
val p = m.chat.part("msg1", "prt1")
assertNotNull(p)
assertEquals("hello world", p!!.text.toString())
}
fun `test MessageRemoved removes from ChatModel`() {
val (m, _) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "user")))
flush()
assertNotNull(m.chat.message("msg1"))
emit(ChatEventDto.MessageRemoved("ses_test", "msg1"))
flush()
assertNull(m.chat.message("msg1"))
}
}
@@ -0,0 +1,577 @@
package ai.kilocode.client.session.model
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.TodoDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.UsefulTestCase
class SessionModelTest : UsefulTestCase() {
private lateinit var model: SessionModel
private lateinit var parent: Disposable
private lateinit var events: MutableList<SessionModelEvent>
override fun setUp() {
super.setUp()
parent = Disposer.newDisposable("test")
model = SessionModel()
events = mutableListOf()
model.addListener(parent) { events.add(it) }
}
override fun tearDown() {
try {
Disposer.dispose(parent)
} finally {
super.tearDown()
}
}
fun `test initial app and workspace state`() {
assertEquals(KiloAppStatusDto.DISCONNECTED, model.app.status)
assertEquals(KiloWorkspaceStatusDto.PENDING, model.workspace.status)
assertFalse(model.isReady())
assertEquals(SessionState.Idle, model.state)
}
fun `test isReady requires app and workspace readiness`() {
model.app = KiloAppStateDto(KiloAppStatusDto.READY)
assertFalse(model.isReady())
model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY)
assertTrue(model.isReady())
}
fun `test addMessage stores entry and fires MessageAdded`() {
model.addMessage(msg("m1", "user"))
val item = model.message("m1")
assertNotNull(item)
assertEquals(1, events.size)
val event = events[0] as SessionModelEvent.MessageAdded
assertEquals("m1", event.info.info.id)
}
fun `test addMessage duplicate is ignored`() {
model.addMessage(msg("m1", "user"))
events.clear()
model.addMessage(msg("m1", "user"))
assertEquals(1, model.messages().size)
assertTrue(events.isEmpty())
}
fun `test removeMessage removes entry and fires MessageRemoved`() {
model.addMessage(msg("m1", "assistant"))
events.clear()
model.removeMessage("m1")
assertNull(model.message("m1"))
assertEquals(1, events.size)
assertEquals("m1", (events[0] as SessionModelEvent.MessageRemoved).id)
}
fun `test removeMessage unknown id is noop`() {
model.removeMessage("unknown")
assertTrue(events.isEmpty())
}
fun `test updateContent text creates Text content and fires ContentAdded`() {
model.addMessage(msg("m1", "assistant"))
events.clear()
model.updateContent("m1", part("p1", "m1", "text", text = "hello"))
val p = model.message("m1")!!.parts["p1"]
assertTrue(p is Text)
assertEquals("hello", (p as Text).content.toString())
val event = events.single() as SessionModelEvent.ContentAdded
assertEquals("m1", event.messageId)
}
fun `test updateContent text replaces content and fires ContentUpdated`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", part("p1", "m1", "text", text = "old"))
events.clear()
model.updateContent("m1", part("p1", "m1", "text", text = "new"))
assertEquals("new", (model.message("m1")!!.parts["p1"] as Text).content.toString())
assertTrue(events.single() is SessionModelEvent.ContentUpdated)
}
fun `test updateContent reasoning creates Reasoning content`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", part("p1", "m1", "reasoning", text = "thinking"))
val p = model.message("m1")!!.parts["p1"]
assertTrue(p is Reasoning)
assertEquals("thinking", (p as Reasoning).content.toString())
}
fun `test updateContent tool creates Tool content and tracks state`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "running", title = "ls"))
val p = model.message("m1")!!.parts["p1"] as Tool
assertEquals("bash", p.name)
assertEquals(ToolExecState.RUNNING, p.state)
assertEquals("ls", p.title)
}
fun `test updateContent tool updates lifecycle`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "pending"))
events.clear()
model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "completed"))
val p = model.message("m1")!!.parts["p1"] as Tool
assertEquals(ToolExecState.COMPLETED, p.state)
assertTrue(events.single() is SessionModelEvent.ContentUpdated)
}
fun `test updateContent compaction creates Compaction content`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", part("p1", "m1", "compaction"))
assertTrue(model.message("m1")!!.parts["p1"] is Compaction)
}
fun `test updateContent unknown type stored as Generic`() {
model.addMessage(msg("m1", "assistant"))
events.clear()
model.updateContent("m1", part("p1", "m1", "step-start"))
val p = model.message("m1")!!.parts["p1"]
assertTrue("Expected Generic fallback but got: ${p?.javaClass?.simpleName}", p is Generic)
assertEquals("step-start", (p as Generic).type)
assertTrue(events.single() is SessionModelEvent.ContentAdded)
}
fun `test appendDelta appends to existing text content`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", part("p1", "m1", "text", text = "hello "))
events.clear()
model.appendDelta("m1", "p1", "world")
assertEquals("hello world", (model.message("m1")!!.parts["p1"] as Text).content.toString())
val event = events.single() as SessionModelEvent.ContentDelta
assertEquals("p1", event.contentId)
assertEquals("world", event.delta)
}
fun `test appendDelta appends to reasoning content`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", part("p1", "m1", "reasoning", text = "hmm "))
events.clear()
model.appendDelta("m1", "p1", "interesting")
assertEquals("hmm interesting", (model.message("m1")!!.parts["p1"] as Reasoning).content.toString())
}
fun `test appendDelta creates text content if missing`() {
model.addMessage(msg("m1", "assistant"))
events.clear()
model.appendDelta("m1", "p1", "first")
val p = model.message("m1")!!.parts["p1"]
assertTrue(p is Text)
assertEquals("first", (p as Text).content.toString())
assertEquals(2, events.size)
assertTrue(events[0] is SessionModelEvent.ContentAdded)
assertTrue(events[1] is SessionModelEvent.ContentDelta)
}
fun `test appendDelta on tool content is noop`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "running"))
events.clear()
model.appendDelta("m1", "p1", "text")
assertTrue(events.isEmpty())
}
fun `test setState stores state and fires StateChanged`() {
val state = SessionState.Busy("thinking")
model.setState(state)
assertEquals(state, model.state)
assertEquals(state, (events.single() as SessionModelEvent.StateChanged).state)
}
fun `test setState to Error stores error data`() {
model.setState(SessionState.Error("something broke", "timeout"))
val state = model.state as SessionState.Error
assertEquals("something broke", state.message)
assertEquals("timeout", state.kind)
}
fun `test setState to AwaitingQuestion stores question`() {
val q = question("q1")
model.setState(SessionState.AwaitingQuestion(q))
assertModel(
"""
question#q1
tool: <none>
header: Pick
prompt: Which option?
option: A - Option A
option: B - Option B
multiple: false
custom: true
""",
)
}
fun `test setState to AwaitingPermission stores permission`() {
val p = permission("p1")
model.setState(SessionState.AwaitingPermission(p))
assertModel(
"""
permission#p1
tool: <none>
name: edit
patterns: *.kt
always: <none>
file: <none>
state: PENDING
metadata: <none>
""",
)
}
fun `test question tool ref is stored in awaiting question state`() {
val q = Question(
id = "q1",
items = listOf(QuestionItem("Pick one", "Choice", listOf(QuestionOption("A", "Option A")), false, true)),
tool = ToolCallRef("msg1", "call1"),
)
model.setState(SessionState.AwaitingQuestion(q))
assertModel(
"""
question#q1
tool: msg1/call1
header: Choice
prompt: Pick one
option: A - Option A
multiple: false
custom: true
""",
)
}
fun `test permission fields are preserved in awaiting permission state`() {
val p = Permission(
id = "p1",
sessionId = "ses",
name = "edit",
patterns = listOf("*.kt"),
always = listOf("src/**"),
meta = PermissionMeta(
rules = listOf("src/**"),
diff = "patch",
filePath = "src/A.kt",
fileDiff = PermissionFileDiff("src/A.kt", additions = 2, deletions = 1),
raw = mapOf("kind" to "edit"),
),
message = "Allow edit?",
tool = ToolCallRef("msg1", "call1"),
state = PermissionRequestState.RESPONDING,
)
model.setState(SessionState.AwaitingPermission(p))
assertModel(
"""
permission#p1
tool: msg1/call1
name: edit
patterns: *.kt
always: src/**
file: src/A.kt
state: RESPONDING
metadata: kind=edit
""",
)
}
fun `test loadHistory populates typed contents and fires HistoryLoaded`() {
model.addMessage(msg("old", "user"))
events.clear()
val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "hello")
val tool = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "tool", tool = "bash", state = "completed", title = "ls")
model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, tool))))
assertNull(model.message("old"))
val entry = model.message("m1")!!
assertTrue(entry.parts["p1"] is Text)
assertEquals("hello", (entry.parts["p1"] as Text).content.toString())
assertTrue(entry.parts["p2"] is Tool)
assertEquals(ToolExecState.COMPLETED, (entry.parts["p2"] as Tool).state)
assertTrue(events.single() is SessionModelEvent.HistoryLoaded)
}
fun `test loadHistory stores unknown content types as Generic`() {
val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "visible")
val snapshot = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "snapshot")
model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, snapshot))))
val entry = model.message("m1")!!
assertTrue(entry.parts.containsKey("p1"))
assertTrue(entry.parts.containsKey("p2"))
assertTrue(entry.parts["p2"] is Generic)
assertEquals("snapshot", (entry.parts["p2"] as Generic).type)
}
fun `test upsertMessage adds new message and returns true`() {
val added = model.upsertMessage(msg("m1", "user"))
assertTrue(added)
assertNotNull(model.message("m1"))
val event = events.single() as SessionModelEvent.MessageAdded
assertEquals("m1", event.info.info.id)
}
fun `test upsertMessage updates existing message and returns false`() {
model.upsertMessage(msg("m1", "user"))
events.clear()
val updated = msg("m1", "assistant") // same id, different role (simulating metadata update)
val added = model.upsertMessage(updated)
assertFalse(added)
assertEquals("assistant", model.message("m1")!!.info.role)
val event = events.single() as SessionModelEvent.MessageUpdated
assertEquals("m1", event.info.info.id)
}
fun `test upsertMessage update preserves existing parts`() {
model.upsertMessage(msg("m1", "assistant"))
model.updateContent("m1", part("p1", "m1", "text", text = "hello"))
events.clear()
model.upsertMessage(msg("m1", "assistant"))
assertNotNull(model.message("m1")!!.parts["p1"])
}
fun `test removeContent fires ContentRemoved`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", part("p1", "m1", "text", text = "hello"))
events.clear()
model.removeContent("m1", "p1")
assertNull(model.message("m1")!!.parts["p1"])
val event = events.single() as SessionModelEvent.ContentRemoved
assertEquals("m1", event.messageId)
assertEquals("p1", event.contentId)
}
fun `test removeContent unknown messageId is noop`() {
events.clear()
model.removeContent("missing", "p1")
assertTrue(events.isEmpty())
}
fun `test removeContent unknown contentId is noop`() {
model.addMessage(msg("m1", "assistant"))
events.clear()
model.removeContent("m1", "missing_part")
assertTrue(events.isEmpty())
}
fun `test setDiff stores diff and fires DiffUpdated`() {
model.setDiff(listOf(
DiffFileDto("src/A.kt", additions = 3, deletions = 1),
DiffFileDto("src/B.kt", additions = 0, deletions = 2),
))
assertModel("diff: src/A.kt src/B.kt")
assertEquals("DiffUpdated files=2", events.single().toString())
}
fun `test setDiff with empty list clears diff`() {
model.setDiff(listOf(DiffFileDto("src/A.kt", additions = 1, deletions = 0)))
events.clear()
model.setDiff(emptyList())
assertModel("")
assertEquals("DiffUpdated files=0", events.single().toString())
}
fun `test setTodos stores todos and fires TodosUpdated`() {
model.setTodos(listOf(
TodoDto("Write tests", "pending", "high"),
TodoDto("Ship it", "in_progress", "medium"),
))
assertModel("""
todo: [pending] Write tests
todo: [in_progress] Ship it
""")
assertEquals("TodosUpdated count=2", events.single().toString())
}
fun `test setTodos with empty list clears todos`() {
model.setTodos(listOf(TodoDto("old task", "completed", "low")))
events.clear()
model.setTodos(emptyList())
assertModel("")
assertEquals("TodosUpdated count=0", events.single().toString())
}
fun `test markCompacted increments count and fires Compacted`() {
model.markCompacted()
assertModel("compacted: 1")
assertEquals("Compacted count=1", events.single().toString())
}
fun `test markCompacted accumulates across multiple calls`() {
model.markCompacted()
model.markCompacted()
model.markCompacted()
assertModel("compacted: 3")
assertEquals("Compacted count=3", events.last().toString())
}
fun `test clear resets diff todos and compactionCount`() {
model.setDiff(listOf(DiffFileDto("a.kt", 1, 0)))
model.setTodos(listOf(TodoDto("task", "pending", "high")))
model.markCompacted()
events.clear()
model.clear()
assertModel("")
assertTrue(events.single() is SessionModelEvent.Cleared)
}
fun `test loadHistory resets diff todos and compactionCount`() {
model.setDiff(listOf(DiffFileDto("a.kt", 1, 0)))
model.setTodos(listOf(TodoDto("task", "pending", "high")))
model.markCompacted()
events.clear()
model.loadHistory(emptyList())
assertModel("")
assertTrue(events.single() is SessionModelEvent.HistoryLoaded)
}
fun `test clear resets messages and state`() {
model.addMessage(msg("m1", "user"))
model.setState(SessionState.Busy("busy"))
model.app = KiloAppStateDto(KiloAppStatusDto.READY)
model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY)
events.clear()
model.clear()
assertTrue(model.isEmpty())
assertEquals(SessionState.Idle, model.state)
assertTrue(events.single() is SessionModelEvent.Cleared)
}
fun `test listener auto removed on dispose`() {
val child = Disposer.newDisposable("child")
Disposer.register(parent, child)
val extra = mutableListOf<SessionModelEvent>()
model.addListener(child) { extra.add(it) }
model.addMessage(msg("m1", "user"))
assertEquals(1, extra.size)
Disposer.dispose(child)
extra.clear()
model.addMessage(msg("m2", "user"))
assertTrue(extra.isEmpty())
}
private fun msg(id: String, role: String) = MessageDto(
id = id,
sessionID = "ses",
role = role,
time = MessageTimeDto(created = 0.0),
)
private fun part(
id: String,
mid: String,
type: String,
text: String? = null,
tool: String? = null,
state: String? = null,
title: String? = null,
) = PartDto(
id = id,
sessionID = "ses",
messageID = mid,
type = type,
text = text,
tool = tool,
state = state,
title = title,
)
private fun question(id: String) = Question(
id = id,
items = listOf(
QuestionItem(
question = "Which option?",
header = "Pick",
options = listOf(QuestionOption("A", "Option A"), QuestionOption("B", "Option B")),
multiple = false,
custom = true,
),
),
)
private fun permission(id: String) = Permission(
id = id,
sessionId = "ses",
name = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
meta = PermissionMeta(),
)
private fun assertModel(expected: String) {
assertEquals(expected.trimIndent().trim(), model.toString().trim())
}
}
@@ -1,44 +0,0 @@
package ai.kilocode.client.session.model
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.rpc.dto.ChatEventDto
class StatusComputationTest : SessionModelTestBase() {
fun `test status shows tool-specific text`() {
val (_, events) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
flush()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
flush()
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash")))
flush()
val status = events.filterIsInstance<SessionEvent.StatusChanged>()
.lastOrNull { it.text != null && it.text != KiloBundle.message("session.status.considering") }
assertNotNull(status)
assertEquals(KiloBundle.message("session.status.commands"), status!!.text)
}
fun `test PartUpdated after TurnClose does not fire StatusChanged`() {
val (_, events) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
flush()
emit(ChatEventDto.TurnOpen("ses_test"))
flush()
emit(ChatEventDto.TurnClose("ses_test", "completed"))
flush()
val before = events.filterIsInstance<SessionEvent.StatusChanged>().size
emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "text", text = "late")))
flush()
val after = events.filterIsInstance<SessionEvent.StatusChanged>().size
assertEquals(before, after)
}
}
@@ -1,53 +0,0 @@
package ai.kilocode.client.session.model
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.MessageErrorDto
class TurnLifecycleTest : SessionModelTestBase() {
fun `test TurnOpen fires BusyChanged true`() {
val (_, events) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
flush()
assertTrue(events.any { it is SessionEvent.BusyChanged && it.busy })
assertTrue(events.any { it is SessionEvent.StatusChanged && it.text == KiloBundle.message("session.status.considering") })
}
fun `test TurnClose fires BusyChanged false and clears status`() {
val (_, events) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
flush()
emit(ChatEventDto.TurnClose("ses_test", "completed"))
flush()
val last = events.filterIsInstance<SessionEvent.BusyChanged>().last()
assertFalse(last.busy)
val status = events.filterIsInstance<SessionEvent.StatusChanged>().last()
assertNull(status.text)
}
fun `test Error fires Error event with message`() {
val (_, events) = prompted()
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Bad Request")))
flush()
val err = events.filterIsInstance<SessionEvent.Error>().firstOrNull()
assertNotNull(err)
assertEquals("Bad Request", err!!.message)
}
fun `test Error with null message falls back to type`() {
val (_, events) = prompted()
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = null)))
flush()
val err = events.filterIsInstance<SessionEvent.Error>().first()
assertEquals("timeout", err.message)
}
}
@@ -1,26 +0,0 @@
package ai.kilocode.client.session.model
class ViewSwitchingTest : SessionModelTestBase() {
fun `test first prompt shows messages view`() {
val m = model()
val events = collect(m)
edt { m.prompt("hello") }
flush()
assertTrue(events.any { it is SessionEvent.ViewChanged && it.show })
}
fun `test ViewChanged not fired twice`() {
val m = model()
val events = collect(m)
edt { m.prompt("first") }
flush()
edt { m.prompt("second") }
flush()
assertEquals(1, events.count { it is SessionEvent.ViewChanged && it.show })
}
}
@@ -1,32 +0,0 @@
package ai.kilocode.client.session.model
class WorkspaceWatchingTest : SessionModelTestBase() {
fun `test workspace ready populates agents and models`() {
val m = model()
val events = collect(m)
flush()
projectRpc.state.value = workspaceReady()
flush()
assertEquals(1, m.chat.agents.size)
assertEquals("code", m.chat.agents[0].name)
assertEquals(1, m.chat.models.size)
assertEquals("gpt-5", m.chat.models[0].id)
assertTrue(m.chat.ready)
assertTrue(events.any { it is SessionEvent.WorkspaceReady })
}
fun `test workspace ready sets default agent and model`() {
val m = model()
collect(m)
flush()
projectRpc.state.value = workspaceReady()
flush()
assertEquals("code", m.chat.agent)
assertEquals("gpt-5", m.chat.model)
}
}
@@ -4,7 +4,12 @@ import ai.kilocode.rpc.KiloSessionRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionListDto
import ai.kilocode.rpc.dto.SessionStatusDto
@@ -43,11 +48,21 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
/** Push status updates here. */
val statuses = MutableStateFlow<Map<String, SessionStatusDto>>(emptyMap())
/** Pending permissions returned by [pendingPermissions]. */
val pendingPermissionList = mutableListOf<PermissionRequestDto>()
/** Pending questions returned by [pendingQuestions]. */
val pendingQuestionList = mutableListOf<QuestionRequestDto>()
// --- Call tracking ---
val prompts = mutableListOf<Triple<String, String, PromptDto>>()
val aborts = mutableListOf<Pair<String, String>>()
val configs = mutableListOf<Pair<String, ConfigUpdateDto>>()
val permissionReplies = mutableListOf<Triple<String, String, PermissionReplyDto>>()
val permissionRulesSaved = mutableListOf<Triple<String, String, PermissionAlwaysRulesDto>>()
val questionReplies = mutableListOf<Triple<String, String, QuestionReplyDto>>()
val questionRejects = mutableListOf<Pair<String, String>>()
var creates = 0
private set
@@ -111,4 +126,34 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
assertNotEdt("updateConfig")
configs.add(directory to config)
}
override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) {
assertNotEdt("replyPermission")
permissionReplies.add(Triple(requestId, directory, reply))
}
override suspend fun savePermissionRules(requestId: String, directory: String, rules: PermissionAlwaysRulesDto) {
assertNotEdt("savePermissionRules")
permissionRulesSaved.add(Triple(requestId, directory, rules))
}
override suspend fun replyQuestion(requestId: String, directory: String, answers: QuestionReplyDto) {
assertNotEdt("replyQuestion")
questionReplies.add(Triple(requestId, directory, answers))
}
override suspend fun rejectQuestion(requestId: String, directory: String) {
assertNotEdt("rejectQuestion")
questionRejects.add(requestId to directory)
}
override suspend fun pendingPermissions(directory: String): List<PermissionRequestDto> {
assertNotEdt("pendingPermissions")
return pendingPermissionList.toList()
}
override suspend fun pendingQuestions(directory: String): List<QuestionRequestDto> {
assertNotEdt("pendingQuestions")
return pendingQuestionList.toList()
}
}
@@ -0,0 +1,401 @@
package ai.kilocode.client.ui.md
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.awt.Color
import java.awt.Font
/**
* Tests for [MdView] created via [MdView.html].
*
* Uses [BasePlatformTestCase] to get a real IntelliJ Application so that
* JBHtmlPane initialisation works correctly.
*/
@Suppress("UnstableApiUsage")
class MdViewTest : BasePlatformTestCase() {
private lateinit var view: MdView
override fun setUp() {
super.setUp()
view = MdView.html()
}
// ---- set ----
fun `test set stores source`() {
view.set("hello **world**")
assertEquals("hello **world**", view.markdown())
}
fun `test set replaces previous content`() {
view.set("first")
view.set("second")
assertEquals("second", view.markdown())
}
fun `test set renders bold`() {
view.set("hello **world**")
assertTrue(view.html().contains("<strong>"))
assertTrue(view.html().contains("world"))
}
fun `test set renders italic`() {
view.set("hello *world*")
assertTrue(view.html().contains("<em>"))
}
fun `test set renders inline code`() {
view.set("use `foo()` here")
assertTrue(view.html().contains("<code>"))
assertTrue(view.html().contains("foo()"))
}
fun `test set renders fenced code block`() {
view.set("```kotlin\nval x = 1\n```")
assertTrue(view.html().contains("<pre>"))
assertTrue(view.html().contains("<code"))
}
fun `test set renders links`() {
view.set("[click](https://example.com)")
assertTrue(view.html().contains("<a"))
assertTrue(view.html().contains("https://example.com"))
}
fun `test set renders headings`() {
view.set("# Title")
assertTrue(view.html().contains("<h1>"))
}
fun `test set renders unordered list`() {
view.set("- one\n- two\n- three")
assertTrue(view.html().contains("<ul>"))
assertTrue(view.html().contains("<li>"))
}
fun `test set renders ordered list`() {
view.set("1. one\n2. two\n3. three")
assertTrue(view.html().contains("<ol>"))
}
fun `test set renders blockquote`() {
view.set("> quoted text")
assertTrue(view.html().contains("<blockquote>"))
}
fun `test set renders strikethrough`() {
view.set("~~deleted~~")
assertTrue(view.html().contains("<del>"))
}
fun `test set renders table`() {
view.set("| a | b |\n|---|---|\n| 1 | 2 |")
assertTrue(view.html().contains("<table>"))
assertTrue(view.html().contains("<th>"))
assertTrue(view.html().contains("<td>"))
}
fun `test set renders autolink`() {
view.set("Visit https://example.com for details")
assertTrue(view.html().contains("<a"))
assertTrue(view.html().contains("https://example.com"))
}
// ---- append ----
fun `test append accumulates source`() {
view.append("hello ")
view.append("**world**")
assertEquals("hello **world**", view.markdown())
}
fun `test append renders accumulated content`() {
view.append("hello ")
view.append("**world**")
assertTrue(view.html().contains("<strong>"))
}
fun `test append after set extends content`() {
view.set("first ")
view.append("second")
assertEquals("first second", view.markdown())
}
// ---- clear ----
fun `test clear resets source`() {
view.set("some content")
view.clear()
assertEquals("", view.markdown())
}
fun `test clear resets rendered html`() {
view.set("some content")
view.clear()
assertFalse(view.html().contains("some content"))
}
// ---- link listener ----
fun `test link listener receives event on activation`() {
val received = mutableListOf<MdView.LinkEvent>()
view.addLinkListener { received.add(it) }
view.set("[click](https://example.com)")
view.simulateLink("https://example.com")
assertEquals(1, received.size)
assertEquals("https://example.com", received[0].href)
}
fun `test multiple link listeners all receive event`() {
val first = mutableListOf<MdView.LinkEvent>()
val second = mutableListOf<MdView.LinkEvent>()
view.addLinkListener { first.add(it) }
view.addLinkListener { second.add(it) }
view.simulateLink("https://a.com")
assertEquals(1, first.size)
assertEquals(1, second.size)
}
fun `test removed listener does not receive events`() {
val received = mutableListOf<MdView.LinkEvent>()
val listener = MdView.LinkListener { received.add(it) }
view.addLinkListener(listener)
view.removeLinkListener(listener)
view.simulateLink("https://example.com")
assertTrue(received.isEmpty())
}
// ---- component ----
fun `test component is not null`() {
assertNotNull(view.component)
}
// ---- complex markdown ----
fun `test complex markdown with mixed elements`() {
val md = """
# Heading
Some **bold** and *italic* text with `code`.
- item one
- item two
```
code block
```
> blockquote
[link](https://example.com)
""".trimIndent()
view.set(md)
val html = view.html()
assertTrue(html.contains("<h1>"))
assertTrue(html.contains("<strong>"))
assertTrue(html.contains("<em>"))
assertTrue(html.contains("<code>"))
assertTrue(html.contains("<ul>"))
assertTrue(html.contains("<pre>"))
assertTrue(html.contains("<blockquote>"))
assertTrue(html.contains("<a"))
}
fun `test streaming simulation appends tokens`() {
val tokens = listOf("Hello ", "**wor", "ld**", "\n\n", "Done.")
for (token in tokens) view.append(token)
assertEquals("Hello **world**\n\nDone.", view.markdown())
assertTrue(view.html().contains("<strong>"))
assertTrue(view.html().contains("Done."))
}
// ---- style overrides (empty by default) ----
fun `test no overrides produces empty override sheet`() {
assertEquals("", view.overrideSheet())
}
// ---- style overrides appear in override sheet when set ----
fun `test foreground override appears in override sheet`() {
view.foreground = Color(0xAA, 0xBB, 0xCC)
view.set("text")
assertTrue(view.overrideSheet().contains("#aabbcc"))
}
fun `test link color override appears in override sheet`() {
view.linkColor = Color(0xFF, 0x00, 0x77)
view.set("[a](https://x.com)")
assertTrue(view.overrideSheet().contains("#ff0077"))
}
fun `test code bg override appears in override sheet`() {
view.codeBg = Color(0x10, 0x20, 0x30)
view.set("`code`")
assertTrue(view.overrideSheet().contains("#102030"))
}
fun `test pre bg and fg overrides appear in override sheet`() {
view.preBg = Color(0x0A, 0x0B, 0x0C)
view.preFg = Color(0xD0, 0xE0, 0xF0)
view.set("```\ncode\n```")
val sheet = view.overrideSheet()
assertTrue(sheet.contains("#0a0b0c"))
assertTrue(sheet.contains("#d0e0f0"))
}
fun `test code font override appears in override sheet`() {
view.codeFont = "Fira Code"
view.set("`x`")
assertTrue(view.overrideSheet().contains("Fira Code"))
}
fun `test blockquote color overrides appear in override sheet`() {
view.quoteBorder = Color(0xAA, 0x00, 0x00)
view.quoteFg = Color(0x00, 0xBB, 0x00)
view.set("> quote")
val sheet = view.overrideSheet()
assertTrue(sheet.contains("#aa0000"))
assertTrue(sheet.contains("#00bb00"))
}
fun `test table border override appears in override sheet`() {
view.tableBorder = Color(0x12, 0x34, 0x56)
view.set("| a |\n|---|\n| 1 |")
assertTrue(view.overrideSheet().contains("#123456"))
}
fun `test font family override appears in override sheet`() {
view.font = Font("Courier New", Font.PLAIN, 14)
view.set("text")
assertTrue(view.overrideSheet().contains("Courier New"))
}
fun `test font size override appears in override sheet`() {
view.font = Font("Arial", Font.PLAIN, 18)
view.set("text")
assertTrue(view.overrideSheet().contains("18pt"))
}
fun `test style change re-renders and override sheet reflects change`() {
view.set("hello")
view.foreground = Color(0xDE, 0xAD, 0x00)
assertTrue(view.overrideSheet().contains("#dead00"))
assertTrue(view.html().contains("hello"))
}
fun `test style change without content does not crash`() {
view.foreground = Color.RED
view.linkColor = Color.BLUE
view.codeFont = "Monospaced"
assertEquals("", view.markdown())
}
// ---- default codeFont uses editor font placeholder ----
fun `test default codeFont is editor font placeholder`() {
// When no codeFont override is set, the getter returns the editor font placeholder
assertTrue(view.codeFont.contains("_Editor"))
}
fun `test default override sheet is empty before any set`() {
// Only overrides appear in the sheet; editor defaults are handled by JBHtmlPane
assertEquals("", view.overrideSheet())
}
// ---- background sets component background ----
fun `test background override sets component background`() {
view.background = Color(0x11, 0x22, 0x33)
assertEquals(Color(0x11, 0x22, 0x33), view.component.background)
}
fun `test background override does not appear in override sheet`() {
// background is applied to the Swing component, not via CSS override rule
view.background = Color(0x11, 0x22, 0x33)
view.set("text")
assertFalse(view.overrideSheet().contains("#112233"))
}
// ---- opaque / transparent ----
fun `test opaque true sets component opaque`() {
view.opaque = true
assertTrue(view.component.isOpaque)
}
fun `test opaque false sets component non-opaque`() {
view.opaque = false
assertFalse(view.component.isOpaque)
}
fun `test opaque false adds transparent background to override sheet`() {
view.opaque = false
view.set("text")
assertTrue(view.overrideSheet().contains("transparent"))
}
fun `test opaque true does not add transparent rule`() {
view.opaque = true
view.set("text")
assertFalse(view.overrideSheet().contains("transparent"))
}
fun `test opaque false does not affect pre background override`() {
view.opaque = false
view.preBg = Color(0x0A, 0x0B, 0x0C)
view.set("```\ncode\n```")
assertTrue(view.overrideSheet().contains("#0a0b0c"))
}
fun `test background override applied to component when opaque is true`() {
view.background = Color(0xFE, 0xFE, 0xFE)
view.opaque = true
assertEquals(Color(0xFE, 0xFE, 0xFE), view.component.background)
}
fun `test opaque toggle updates component opacity`() {
view.opaque = false
assertFalse(view.component.isOpaque)
view.opaque = true
assertTrue(view.component.isOpaque)
}
// ---- resetStyles ----
fun `test resetStyles clears foreground override`() {
view.foreground = Color.RED
view.resetStyles()
assertEquals("", view.overrideSheet())
}
fun `test resetStyles clears all overrides`() {
view.foreground = Color.RED
view.linkColor = Color.BLUE
view.codeBg = Color.GREEN
view.preBg = Color.ORANGE
view.preFg = Color.CYAN
view.codeFont = "Monospaced"
view.quoteBorder = Color.PINK
view.quoteFg = Color.MAGENTA
view.tableBorder = Color.YELLOW
view.font = Font("Arial", Font.PLAIN, 18)
view.resetStyles()
assertEquals("", view.overrideSheet())
}
fun `test resetStyles restores opaque to true`() {
view.opaque = false
view.resetStyles()
assertTrue(view.component.isOpaque)
}
fun `test resetStyles after set still renders content`() {
view.set("hello **world**")
view.foreground = Color.RED
view.resetStyles()
assertTrue(view.html().contains("<strong>"))
}
}
@@ -1,15 +1,20 @@
[versions]
intellij-platform = "2025.3"
intellij-platform = "2026.1"
intellij-gradle-plugin = "2.14.0"
intellij-rpc-plugin = "2.3.20-0.1"
intellij-rpc-plugin = "2.3.20-RC2-0.1"
kotlin-jvm-plugin = "2.3.20"
kotlin-serialization-plugin = "2.3.20"
kotlin-serialization = "1.11.0"
okhttp = "4.12.0"
openapi-generator = "7.21.0"
detekt = "1.23.8"
commonmark = "0.28.0"
[libraries]
commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
commonmark-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" }
commonmark-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" }
commonmark-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
@@ -3,7 +3,12 @@ package ai.kilocode.rpc
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionListDto
import ai.kilocode.rpc.dto.SessionStatusDto
@@ -66,4 +71,24 @@ interface KiloSessionRpcApi : RemoteApi<Unit> {
/** Update config (model, agent/mode, temperature). */
suspend fun updateConfig(directory: String, config: ConfigUpdateDto)
// ------ permission / question resolution ------
/** Reply to a pending permission request (once, always, or reject). */
suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto)
/** Save always-rules for a pending permission request before replying. */
suspend fun savePermissionRules(requestId: String, directory: String, rules: PermissionAlwaysRulesDto)
/** Reply to a pending question with user answers. */
suspend fun replyQuestion(requestId: String, directory: String, answers: QuestionReplyDto)
/** Reject a pending question. */
suspend fun rejectQuestion(requestId: String, directory: String)
/** List all pending permission requests (caller filters by session). */
suspend fun pendingPermissions(directory: String): List<PermissionRequestDto>
/** List all pending question requests (caller filters by session). */
suspend fun pendingQuestions(directory: String): List<QuestionRequestDto>
}
@@ -47,7 +47,7 @@ data class MessageWithPartsDto(
val parts: List<PartDto>,
)
// --- Parts (simplified for basic chat) ---
// --- Parts ---
@Serializable
data class PartDto(
@@ -57,6 +57,7 @@ data class PartDto(
val type: String,
val text: String? = null,
val tool: String? = null,
val callID: String? = null,
val state: String? = null,
val title: String? = null,
)
@@ -90,14 +91,14 @@ sealed class ChatEventDto {
) : ChatEventDto()
@Serializable
@SerialName("part.updated")
@SerialName("message.part.updated")
data class PartUpdated(
val sessionID: String,
val part: PartDto,
) : ChatEventDto()
@Serializable
@SerialName("part.delta")
@SerialName("message.part.delta")
data class PartDelta(
val sessionID: String,
val messageID: String,
@@ -107,20 +108,28 @@ sealed class ChatEventDto {
) : ChatEventDto()
@Serializable
@SerialName("turn.open")
@SerialName("message.part.removed")
data class PartRemoved(
val sessionID: String,
val messageID: String,
val partID: String,
) : ChatEventDto()
@Serializable
@SerialName("session.turn.open")
data class TurnOpen(
val sessionID: String,
) : ChatEventDto()
@Serializable
@SerialName("turn.close")
@SerialName("session.turn.close")
data class TurnClose(
val sessionID: String,
val reason: String,
) : ChatEventDto()
@Serializable
@SerialName("error")
@SerialName("session.error")
data class Error(
val sessionID: String?,
val error: MessageErrorDto? = null,
@@ -132,8 +141,156 @@ sealed class ChatEventDto {
val sessionID: String,
val messageID: String,
) : ChatEventDto()
@Serializable
@SerialName("permission.asked")
data class PermissionAsked(
val sessionID: String,
val request: PermissionRequestDto,
) : ChatEventDto()
@Serializable
@SerialName("permission.replied")
data class PermissionReplied(
val sessionID: String,
val requestID: String,
) : ChatEventDto()
@Serializable
@SerialName("question.asked")
data class QuestionAsked(
val sessionID: String,
val request: QuestionRequestDto,
) : ChatEventDto()
@Serializable
@SerialName("question.replied")
data class QuestionReplied(
val sessionID: String,
val requestID: String,
) : ChatEventDto()
@Serializable
@SerialName("question.rejected")
data class QuestionRejected(
val sessionID: String,
val requestID: String,
) : ChatEventDto()
@Serializable
@SerialName("session.status")
data class SessionStatusChanged(
val sessionID: String,
val status: SessionStatusDto,
) : ChatEventDto()
@Serializable
@SerialName("session.idle")
data class SessionIdle(
val sessionID: String,
) : ChatEventDto()
@Serializable
@SerialName("session.compacted")
data class SessionCompacted(
val sessionID: String,
) : ChatEventDto()
@Serializable
@SerialName("session.diff")
data class SessionDiffChanged(
val sessionID: String,
val diff: List<DiffFileDto> = emptyList(),
) : ChatEventDto()
@Serializable
@SerialName("todo.updated")
data class TodoUpdated(
val sessionID: String,
val todos: List<TodoDto> = emptyList(),
) : ChatEventDto()
}
// --- Permission DTOs ---
@Serializable
data class PermissionRequestDto(
val id: String,
val sessionID: String,
val permission: String,
val patterns: List<String>,
val metadata: Map<String, String> = emptyMap(),
val always: List<String> = emptyList(),
val tool: ToolRefDto? = null,
)
@Serializable
data class ToolRefDto(
val messageID: String,
val callID: String,
)
@Serializable
data class PermissionReplyDto(
val reply: String,
val message: String? = null,
)
@Serializable
data class PermissionAlwaysRulesDto(
val approvedAlways: List<String> = emptyList(),
val deniedAlways: List<String> = emptyList(),
)
// --- Question DTOs ---
@Serializable
data class QuestionRequestDto(
val id: String,
val sessionID: String,
val questions: List<QuestionInfoDto>,
val tool: ToolRefDto? = null,
)
@Serializable
data class QuestionInfoDto(
val question: String,
val header: String,
val options: List<QuestionOptionDto> = emptyList(),
val multiple: Boolean = false,
val custom: Boolean = true,
)
@Serializable
data class QuestionOptionDto(
val label: String,
val description: String,
)
@Serializable
data class QuestionReplyDto(
val answers: List<List<String>>,
)
// --- Todo DTO ---
@Serializable
data class TodoDto(
val content: String,
val status: String,
val priority: String,
)
// --- Diff DTO ---
@Serializable
data class DiffFileDto(
val file: String,
val additions: Int,
val deletions: Int,
val patch: String? = null,
)
// --- Config Update ---
@Serializable
@@ -32,6 +32,9 @@ data class SessionSummaryDto(
data class SessionStatusDto(
val type: String,
val message: String? = null,
val attempt: Int? = null,
val next: Long? = null,
val requestID: String? = null,
)
@Serializable
@@ -4,18 +4,6 @@ import type { KiloConnectionService } from "../cli-backend"
const DEFAULT_MODEL = "mistralai/codestral-2508"
const PROVIDER_DISPLAY_NAME = "Kilo Gateway"
/** Chunk from an LLM streaming response */
export type ApiStreamChunk =
| { type: "text"; text: string }
| {
type: "usage"
totalCost?: number
inputTokens?: number
outputTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
}
export class AutocompleteModel {
private connectionService: KiloConnectionService | null = null
public profileName: string | null = null
@@ -34,10 +22,6 @@ export class AutocompleteModel {
this.connectionService = service
}
public supportsFim(): boolean {
return true
}
/**
* Generate a FIM (Fill-in-the-Middle) completion via the CLI backend.
* Uses the SDK's kilo.fim() SSE endpoint which handles auth and streaming.
@@ -102,20 +86,6 @@ export class AutocompleteModel {
}
}
/**
* Generate response via chat completions (holefiller fallback).
* Not used when FIM is supported, but kept for compatibility.
*/
public async generateResponse(
systemPrompt: string,
userPrompt: string,
onChunk: (chunk: ApiStreamChunk) => void,
): Promise<ResponseMetaData> {
// FIM is the primary strategy; this method is a fallback.
// For now, throw — callers should use generateFimResponse via supportsFim().
throw new Error("Chat-based completions are not supported via CLI backend. Use FIM (supportsFim() returns true).")
}
public getModelName(): string {
return DEFAULT_MODEL
}
@@ -75,13 +75,6 @@ describe("AutocompleteModel", () => {
})
})
describe("supportsFim", () => {
it("always returns true", () => {
const model = new AutocompleteModel()
expect(model.supportsFim()).toBe(true)
})
})
describe("getModelName", () => {
it("returns the default model", () => {
const model = new AutocompleteModel()
@@ -165,13 +158,4 @@ describe("AutocompleteModel", () => {
)
})
})
describe("generateResponse", () => {
it("throws because FIM is the primary strategy", async () => {
const model = new AutocompleteModel()
await expect(model.generateResponse("system", "user", vi.fn())).rejects.toThrow(
"Chat-based completions are not supported via CLI backend",
)
})
})
})
@@ -98,22 +98,9 @@ export class ChatTextAreaAutocomplete {
let response = ""
try {
// Use FIM if supported, otherwise fall back to chat-based completion
if (this.model.supportsFim()) {
await this.model.generateFimResponse(prefix, suffix, (chunk) => {
response += chunk
})
} else {
// Fall back to chat-based completion for models without FIM support
const systemPrompt = this.getChatSystemPrompt()
const userPrompt = this.getChatUserPrompt(prefix)
await this.model.generateResponse(systemPrompt, userPrompt, (chunk) => {
if (chunk.type === "text") {
response += chunk.text
}
})
}
await this.model.generateFimResponse(prefix, suffix, (chunk) => {
response += chunk
})
const latencyMs = Date.now() - startTime
@@ -153,35 +140,6 @@ export class ChatTextAreaAutocomplete {
}
}
/**
* Get system prompt for chat-based completion
*/
private getChatSystemPrompt(): string {
return `You are an intelligent chat completion assistant. Your task is to complete the user's message naturally based on the provided context.
## RULES
- Provide a natural, conversational completion
- Be concise - typically 1-15 words
- Match the user's tone and style
- Use context from visible code if relevant
- NEVER repeat what the user already typed
- NEVER start with comments (//, /*, #)
- If the user is in the middle of typing a word (e.g., "hel"), include the COMPLETE word in your response (e.g., "hello world" not just "lo world")
- This allows proper prefix matching to remove the overlap correctly
- Return ONLY the completion text, no explanations or formatting`
}
/**
* Get user prompt for chat-based completion
*/
private getChatUserPrompt(prefix: string): string {
return `${prefix}
TASK: Complete the user's message naturally.
- If the user is mid-word (e.g., typed "hel"), return the COMPLETE word (e.g., "hello world") so prefix matching can work correctly
- Return ONLY the completion text (what comes next), no explanations.`
}
private async buildPrefix(userText: string, visibleCodeContext?: VisibleCodeContext): Promise<string> {
return buildChatPrefix(userText, visibleCodeContext?.editors)
}
@@ -1,84 +0,0 @@
import { ChatTextAreaAutocomplete } from "../ChatTextAreaAutocomplete"
import { ProviderSettingsManager } from "../../../../core/config/ProviderSettingsManager"
import { AutocompleteModel } from "../../AutocompleteModel"
import { ApiStreamChunk } from "../../../../api/transform/stream"
describe("ChatTextAreaAutocomplete", () => {
let autocomplete: ChatTextAreaAutocomplete
let mockProviderSettingsManager: ProviderSettingsManager
beforeEach(() => {
mockProviderSettingsManager = {} as ProviderSettingsManager
autocomplete = new ChatTextAreaAutocomplete(mockProviderSettingsManager)
})
describe("getCompletion", () => {
it("should work with non-FIM models using chat-based completion", async () => {
// Setup: Model without FIM support (like Mistral)
const mockModel = new AutocompleteModel()
vi.spyOn(mockModel, "hasValidCredentials").mockReturnValue(true)
vi.spyOn(mockModel, "supportsFim").mockReturnValue(false)
vi.spyOn(mockModel, "generateResponse").mockImplementation(async (systemPrompt, userPrompt, onChunk) => {
// Simulate streaming chat response
const chunks: ApiStreamChunk[] = [{ type: "text", text: "write a function" }]
for (const chunk of chunks) {
onChunk(chunk)
}
return {
cost: 0,
inputTokens: 15,
outputTokens: 8,
cacheWriteTokens: 0,
cacheReadTokens: 0,
}
})
// @ts-expect-error - accessing private property for test
autocomplete.model = mockModel
const result = await autocomplete.getCompletion("How to ")
expect(mockModel.generateResponse).toHaveBeenCalled()
expect(result.suggestion).toBe("write a function")
})
})
describe("cleanSuggestion", () => {
it("should filter code patterns (comments, preprocessor)", () => {
// Comments - filtered by the regex check in cleanSuggestion
expect(autocomplete.cleanSuggestion("// comment", "")).toBe("")
expect(autocomplete.cleanSuggestion("/* comment", "")).toBe("")
expect(autocomplete.cleanSuggestion("* something", "")).toBe("")
// Code patterns
expect(autocomplete.cleanSuggestion("#include", "")).toBe("")
expect(autocomplete.cleanSuggestion("# Header", "")).toBe("")
})
it("should filter empty content", () => {
// Empty content is filtered by postprocessAutocompleteSuggestion
expect(autocomplete.cleanSuggestion("", "")).toBe("")
})
it("should accept natural language suggestions", () => {
expect(autocomplete.cleanSuggestion("Hello world", "")).toBe("Hello world")
expect(autocomplete.cleanSuggestion("Can you help me", "")).toBe("Can you help me")
expect(autocomplete.cleanSuggestion("test123", "")).toBe("test123")
expect(autocomplete.cleanSuggestion("What's up?", "")).toBe("What's up?")
})
it("should accept symbols in middle of text", () => {
expect(autocomplete.cleanSuggestion("Text with # in middle", "")).toBe("Text with # in middle")
expect(autocomplete.cleanSuggestion("Hello // but not a comment", "")).toBe("Hello // but not a comment")
})
it("should truncate at first newline", () => {
expect(autocomplete.cleanSuggestion("First line\nSecond line", "")).toBe("First line")
})
it("should remove prefix overlap", () => {
expect(autocomplete.cleanSuggestion("Hello world", "Hello ")).toBe("world")
})
})
})
@@ -851,7 +851,6 @@ describe("AutocompleteInlineCompletionProvider", () => {
}),
getModelName: vi.fn().mockReturnValue("test-model"),
getProviderDisplayName: vi.fn().mockReturnValue("test-provider"),
supportsFim: vi.fn().mockReturnValue(true),
hasValidCredentials: vi.fn().mockReturnValue(true), // Default to true for tests
} as unknown as AutocompleteModel
mockCostTrackingCallback = vi.fn() as CostTrackingCallback
@@ -42,25 +42,15 @@ describe("postprocessAutocompleteSuggestion", () => {
expect(result).toBe("test")
})
it("handles Mercury/Granite prefix duplication", () => {
it("handles Mercury prefix duplication", () => {
const result = postprocessAutocompleteSuggestion({
suggestion: "const x = 42",
prefix: "const x = ",
suffix: "",
model: "granite-20b",
model: "inception/mercury-edit",
})
expect(result).toBe("42")
})
it("handles Gemini/Gemma file separator", () => {
const result = postprocessAutocompleteSuggestion({
suggestion: "const x = 1<|file_separator|>",
prefix: "",
suffix: "",
model: "gemini-pro",
})
expect(result).toBe("const x = 1")
})
})
describe("extreme repetition filtering", () => {
@@ -133,7 +133,7 @@ function normalizeToCompleteLine(params: AutocompleteSuggestion): AutocompleteSu
* @param params.suggestion - The suggested text to insert
* @param params.prefix - The text before the cursor position
* @param params.suffix - The text after the cursor position
* @param params.model - The model string (e.g., "codestral", "qwen3", etc.)
* @param params.model - The model string (e.g., "codestral", "mercury", etc.)
* @param params.languageId - Optional language ID for language-specific filtering
* @returns The processed suggestion text, or undefined if it should be filtered out
*/
@@ -133,34 +133,18 @@ export function postprocessCompletion({
}
}
if (llm.model.includes("qwen3")) {
// Qwen3 always starts from special thinking markers, and we don't want them to output these contents
// Remove all content from "
completion = completion.replace(/<think>.*?<\/think>/s, "")
completion = completion.replace(/<\/think>/, "")
// Remove any number of newline characters at the beginning and end
completion = completion.replace(/^\n+|\n+$/g, "")
}
if (llm.model.includes("mercury") || llm.model.includes("granite")) {
if (llm.model.includes("mercury")) {
completion = removePrefixOverlap(completion, prefix)
}
// // If completion starts with multiple whitespaces, but the cursor is at the end of the line
// // then it should probably be on a new line
if (
llm.model.includes("mercury") &&
(completion.startsWith(" ") || completion.startsWith("\t")) &&
!prefix.endsWith("\n") &&
(suffix.startsWith("\n") || suffix.trim().length === 0)
) {
completion = "\n" + completion
}
if ((llm.model.includes("gemini") || llm.model.includes("gemma")) && completion.endsWith("<|file_separator|>")) {
// "<|file_separator|>" is 18 characters long
completion = completion.slice(0, -18)
// If completion starts with multiple whitespaces, but the cursor is at the
// end of the line then it should probably be on a new line
if (
(completion.startsWith(" ") || completion.startsWith("\t")) &&
!prefix.endsWith("\n") &&
(suffix.startsWith("\n") || suffix.trim().length === 0)
) {
completion = "\n" + completion
}
}
// If prefix ends with space and so does completion, then remove the space from completion
@@ -1,8 +1,11 @@
// Fill in the middle prompts
//
// We only expose Codestral and Mercury Edit as autocomplete models — every
// other FIM template in the upstream continuedev list is unreachable.
import { CompletionOptions } from "../../index.js"
import { getLastNUriRelativePathParts, getShortestUniqueRelativeUriPaths } from "../../util/uri.js"
import { AutocompleteCodeSnippet, AutocompleteSnippet, AutocompleteSnippetType } from "../types.js"
import { AutocompleteSnippet, AutocompleteSnippetType } from "../types.js"
type TemplateRenderer = (
prefix: string,
@@ -27,55 +30,6 @@ export interface AutocompleteTemplate {
completionOptions?: Partial<CompletionOptions>
}
// https://huggingface.co/stabilityai/stable-code-3b
const stableCodeFimTemplate: AutocompleteTemplate = {
template: (prefix: string, suffix: string): string => {
return `<fim_prefix>${prefix}<fim_suffix>${suffix}<fim_middle>`
},
completionOptions: {
stop: ["<fim_prefix>", "<fim_suffix>", "<fim_middle>", "<file_sep>", "<|endoftext|>", "</fim_middle>", "</code>"],
},
}
// https://github.com/QwenLM/Qwen2.5-Coder?tab=readme-ov-file#3-file-level-code-completion-fill-in-the-middle
// This issue asks about the use of <|repo_name|> and <|file_sep|> together with <|fim_prefix|>, <|fim_suffix|> and <|fim_middle|>
// https://github.com/QwenLM/Qwen2.5-Coder/issues/343
const qwenCoderFimTemplate: AutocompleteTemplate = {
template: (prefix: string, suffix: string): string => {
return `<|fim_prefix|>${prefix}<|fim_suffix|>${suffix}<|fim_middle|>`
},
completionOptions: {
stop: [
"<|endoftext|>",
"<|fim_prefix|>",
"<|fim_middle|>",
"<|fim_suffix|>",
"<|fim_pad|>",
"<|repo_name|>",
"<|file_sep|>",
"<|im_start|>",
"<|im_end|>",
],
},
}
const seedCoderFimTemplate: AutocompleteTemplate = {
template: (prefix: string, suffix: string): string => {
return `<[fim-prefix]>${prefix}<[fim-suffix]>${suffix}<[fim-middle]>`
},
completionOptions: {
stop: [
"<[end▁of▁sentence]>",
"<[fim-prefix]>",
"<[fim-middle]>",
"<[fim-suffix]>",
"<[PAD▁TOKEN]>",
"<[SEP▁TOKEN]>",
"<[begin▁of▁sentence]>",
],
},
}
const codestralMultifileFimTemplate: AutocompleteTemplate = {
compilePrefixSuffix: (prefix, suffix, filepath, _reponame, snippets, workspaceUris): [string, string] => {
function getFileName(snippet: { uri: string; uniquePath: string }) {
@@ -158,217 +112,9 @@ const mercuryMultifileFimTemplate: AutocompleteTemplate = {
},
}
const codegemmaFimTemplate: AutocompleteTemplate = {
template: (prefix: string, suffix: string): string => {
return `<|fim_prefix|>${prefix}<|fim_suffix|>${suffix}<|fim_middle|>`
},
completionOptions: {
stop: ["<|fim_prefix|>", "<|fim_suffix|>", "<|fim_middle|>", "<|file_separator|>", "<end_of_turn>", "<eos>"],
},
}
const codeLlamaFimTemplate: AutocompleteTemplate = {
template: (prefix: string, suffix: string): string => {
return `<PRE> ${prefix} <SUF>${suffix} <MID>`
},
completionOptions: { stop: ["<PRE>", "<SUF>", "<MID>", "<EOT>"] },
}
// https://huggingface.co/deepseek-ai/deepseek-coder-1.3b-base
const deepseekFimTemplate: AutocompleteTemplate = {
template: (prefix: string, suffix: string): string => {
return `<|fim▁begin|>${prefix}<|fim▁hole|>${suffix}<|fim▁end|>`
},
completionOptions: {
stop: ["<|fim▁begin|>", "<|fim▁hole|>", "<|fim▁end|>", "//", "<|end▁of▁sentence|>"],
},
}
// https://github.com/THUDM/CodeGeeX4/blob/main/guides/Infilling_guideline.md
const codegeexFimTemplate: AutocompleteTemplate = {
template: (prefix, suffix, filepath, _reponame, language, allSnippets, workspaceUris): string => {
const snippets = allSnippets.filter(
(snippet) => snippet.type === AutocompleteSnippetType.Code,
) as AutocompleteCodeSnippet[]
const relativePaths = getShortestUniqueRelativeUriPaths(
[...snippets.map((snippet) => snippet.filepath), filepath],
workspaceUris,
)
const baseTemplate = `###PATH:${
relativePaths[relativePaths.length - 1]
}\n###LANGUAGE:${language}\n###MODE:BLOCK\n<|code_suffix|>${suffix}<|code_prefix|>${prefix}<|code_middle|>`
if (snippets.length === 0) {
return `<|user|>\n${baseTemplate}<|assistant|>\n`
}
const references = `###REFERENCE:\n${snippets
.map((snippet, i) => `###PATH:${relativePaths[i]}\n${snippet.content}\n`)
.join("###REFERENCE:\n")}`
const prompt = `<|user|>\n${references}\n${baseTemplate}<|assistant|>\n`
return prompt
},
completionOptions: {
stop: ["<|user|>", "<|code_suffix|>", "<|code_prefix|>", "<|code_middle|>", "<|assistant|>", "<|endoftext|>"],
},
}
const holeFillerTemplate: AutocompleteTemplate = {
template: (prefix: string, suffix: string) => {
// From https://github.com/VictorTaelin/AI-scripts
const SYSTEM_MSG = `You are a HOLE FILLER. You are provided with a file containing holes, formatted as '{{HOLE_NAME}}'. Your TASK is to complete with a string to replace this hole with, inside a <COMPLETION/> XML tag, including context-aware indentation, if needed. All completions MUST be truthful, accurate, well-written and correct.
## EXAMPLE QUERY:
<QUERY>
function sum_evens(lim) {
var sum = 0;
for (var i = 0; i < lim; ++i) {
{{FILL_HERE}}
}
return sum;
}
</QUERY>
TASK: Fill the {{FILL_HERE}} hole.
## CORRECT COMPLETION
<COMPLETION>if (i % 2 === 0) {
sum += i;
}</COMPLETION>
## EXAMPLE QUERY:
<QUERY>
def sum_list(lst):
total = 0
for x in lst:
{{FILL_HERE}}
return total
print sum_list([1, 2, 3])
</QUERY>
## CORRECT COMPLETION:
<COMPLETION> total += x</COMPLETION>
## EXAMPLE QUERY:
<QUERY>
// data Tree a = Node (Tree a) (Tree a) | Leaf a
// sum :: Tree Int -> Int
// sum (Node lft rgt) = sum lft + sum rgt
// sum (Leaf val) = val
// convert to TypeScript:
{{FILL_HERE}}
</QUERY>
## CORRECT COMPLETION:
<COMPLETION>type Tree<T>
= {$:"Node", lft: Tree<T>, rgt: Tree<T>}
| {$:"Leaf", val: T};
function sum(tree: Tree<number>): number {
switch (tree.$) {
case "Node":
return sum(tree.lft) + sum(tree.rgt);
case "Leaf":
return tree.val;
}
}</COMPLETION>
## EXAMPLE QUERY:
The 5th {{FILL_HERE}} is Jupiter.
## CORRECT COMPLETION:
<COMPLETION>planet from the Sun</COMPLETION>
## EXAMPLE QUERY:
function hypothenuse(a, b) {
return Math.sqrt({{FILL_HERE}}b ** 2);
}
## CORRECT COMPLETION:
<COMPLETION>a ** 2 + </COMPLETION>`
const fullPrompt =
SYSTEM_MSG +
`\n\n<QUERY>\n${prefix}{{FILL_HERE}}${suffix}\n</QUERY>\nTASK: Fill the {{FILL_HERE}} hole. Answer only with the CORRECT completion, and NOTHING ELSE. Do it now.\n<COMPLETION>`
return fullPrompt
},
completionOptions: {
stop: ["</COMPLETION>"],
},
}
export function getTemplateForModel(model: string): AutocompleteTemplate {
const lowerCaseModel = model.toLowerCase()
// if (lowerCaseModel.includes("starcoder2")) {
// return starcoder2FimTemplate;
// }
if (lowerCaseModel.includes("mercury")) {
if (model.toLowerCase().includes("mercury")) {
return mercuryMultifileFimTemplate
}
if (lowerCaseModel.includes("qwen") && lowerCaseModel.includes("coder")) {
return qwenCoderFimTemplate
}
if (lowerCaseModel.includes("seed") && lowerCaseModel.includes("coder")) {
return seedCoderFimTemplate
}
if (
lowerCaseModel.includes("starcoder") ||
lowerCaseModel.includes("star-coder") ||
lowerCaseModel.includes("starchat") ||
lowerCaseModel.includes("octocoder") ||
lowerCaseModel.includes("stable") ||
lowerCaseModel.includes("codeqwen") ||
lowerCaseModel.includes("qwen")
) {
return stableCodeFimTemplate
}
if (lowerCaseModel.includes("codestral")) {
return codestralMultifileFimTemplate
}
if (lowerCaseModel.includes("codegemma")) {
return codegemmaFimTemplate
}
if (lowerCaseModel.includes("codellama")) {
return codeLlamaFimTemplate
}
if (lowerCaseModel.includes("deepseek")) {
return deepseekFimTemplate
}
if (lowerCaseModel.includes("codegeex")) {
return codegeexFimTemplate
}
// if (
// lowerCaseModel.includes("gpt") ||
// lowerCaseModel.includes("davinci-002") ||
// lowerCaseModel.includes("claude") ||
// lowerCaseModel.includes("granite3") ||
// lowerCaseModel.includes("granite-3")
// ) {
// Default Fallback Mode
return holeFillerTemplate
//}
//return stableCodeFimTemplate
return codestralMultifileFimTemplate
}
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test"
import { dict as ar } from "@kilocode/kilo-i18n/ar"
import { dict as br } from "@kilocode/kilo-i18n/br"
import { dict as bs } from "@kilocode/kilo-i18n/bs"
import { dict as da } from "@kilocode/kilo-i18n/da"
import { dict as de } from "@kilocode/kilo-i18n/de"
import { dict as en } from "@kilocode/kilo-i18n/en"
import { dict as es } from "@kilocode/kilo-i18n/es"
import { dict as fr } from "@kilocode/kilo-i18n/fr"
import { dict as ja } from "@kilocode/kilo-i18n/ja"
import { dict as ko } from "@kilocode/kilo-i18n/ko"
import { dict as nl } from "@kilocode/kilo-i18n/nl"
import { dict as no } from "@kilocode/kilo-i18n/no"
import { dict as pl } from "@kilocode/kilo-i18n/pl"
import { dict as ru } from "@kilocode/kilo-i18n/ru"
import { dict as th } from "@kilocode/kilo-i18n/th"
import { dict as tr } from "@kilocode/kilo-i18n/tr"
import { dict as uk } from "@kilocode/kilo-i18n/uk"
import { dict as zh } from "@kilocode/kilo-i18n/zh"
import { dict as zht } from "@kilocode/kilo-i18n/zht"
const dicts: Record<string, Record<string, string>> = {
ar,
br,
bs,
da,
de,
en,
es,
fr,
ja,
ko,
nl,
no,
pl,
ru,
th,
tr,
uk,
zh,
zht,
}
const keys = [
"plan.followup.header",
"plan.followup.question",
"plan.followup.answer.newSession",
"plan.followup.answer.newSession.description",
"plan.followup.answer.continue",
"plan.followup.answer.continue.description",
]
describe("plan follow-up i18n keys", () => {
for (const locale of Object.keys(dicts)) {
test(`${locale} defines every plan.followup.* key`, () => {
const d = dicts[locale]!
for (const key of keys) {
const value = d[key]
expect(value, `${locale} is missing ${key}`).toBeDefined()
expect(value, `${locale} has empty value for ${key}`).toBeTruthy()
}
})
}
})
@@ -5,6 +5,7 @@ import {
resolveQuestionMode,
resolveSelectedQuestionMode,
toggleAnswer,
tr,
} from "../../webview-ui/src/components/chat/question-dock-utils"
describe("toggleAnswer", () => {
@@ -151,3 +152,32 @@ describe("pickOutcome", () => {
expect(pickOutcome({ single: false, multi: true, custom: true })).toEqual({ kind: "stay" })
})
})
describe("tr", () => {
it("returns the translated value when the key is present", () => {
const dict: Record<string, string> = { "plan.followup.answer.continue": "Continuer ici" }
const t = (key: string) => dict[key] ?? key
expect(tr(t, "plan.followup.answer.continue", "Continue here")).toBe("Continuer ici")
})
it("returns the fallback when key is undefined", () => {
const t = (key: string) => key
expect(tr(t, undefined, "Continue here")).toBe("Continue here")
})
it("returns the fallback when the key is missing from the dict (language.t echoes the key)", () => {
const t = (key: string) => key
expect(tr(t, "plan.followup.answer.continue", "Continue here")).toBe("Continue here")
})
it("returns an empty string fallback when no key and no label are available", () => {
const t = (key: string) => key
expect(tr(t, undefined, "")).toBe("")
})
it("returns the translated value even when the fallback is empty", () => {
const dict: Record<string, string> = { "plan.followup.question": "Prêt à implémenter ?" }
const t = (key: string) => dict[key] ?? key
expect(tr(t, "plan.followup.question", "")).toBe("Prêt à implémenter ?")
})
})
@@ -17,6 +17,7 @@ import {
resolveOptimisticQuestionAgent,
resolveSelectedQuestionMode,
toggleAnswer,
tr,
} from "./question-dock-utils"
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
@@ -69,6 +70,25 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
return language.t("question.summary", { n, total: total() })
})
// Localized view of the current question. The wire-format `label` is preserved for reply
// matching; only the display text goes through `tr()`.
//
// translateOption returns accessor functions, not plain strings. SolidJS runs the <For>
// child callback in an untracked scope (via mapArray), so reading `language.t(...)` once
// at construction would freeze translations at the first render. Accessors force every
// JSX read to happen inside the binding's tracking scope, so switching the sidebar
// language while a question dock is visible re-renders the option labels.
const questionText = createMemo(() => tr(language.t, question()?.questionKey, question()?.question ?? ""))
const translateOption = (opt: {
label: string
description?: string
labelKey?: string
descriptionKey?: string
}) => ({
label: () => tr(language.t, opt.labelKey, opt.label),
description: () => (opt.description ? tr(language.t, opt.descriptionKey, opt.description) : ""),
})
const focusPrompt = () => requestAnimationFrame(() => window.dispatchEvent(new Event("focusPrompt")))
const reply = (answers: string[][]) => {
@@ -128,6 +148,11 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
if (outcome.kind === "submit") {
// Mirror TUI behaviour: a single-question single-select option pick submits immediately.
// handleCustomSubmit covers the custom-input path via its own submit() call.
//
// NOTE: This auto-submit applies to every single-question single-select prompt, not only
// the plan follow-up. If a future caller needs the user to review before submitting, set
// `multiple: true` on the question (that path stays on the current tab and waits for the
// footer Submit button).
reply([[answer]])
return
}
@@ -285,7 +310,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
<div data-slot="question-dock-header-content">
<div data-slot="question-header-title">{summary()}</div>
<Show when={store.collapsed}>
<div data-slot="question-collapsed-preview">{question()?.question}</div>
<div data-slot="question-collapsed-preview">{questionText()}</div>
</Show>
</div>
<div data-slot="question-header-actions" onClick={(e: MouseEvent) => e.stopPropagation()}>
@@ -328,7 +353,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
<div data-slot="question-dock-body" inert={store.collapsed || undefined}>
<div data-slot="question-dock-body-inner">
<Show when={!confirm()}>
<div data-slot="question-text">{question()?.question}</div>
<div data-slot="question-text">{questionText()}</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
@@ -336,6 +361,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
const localized = translateOption(opt)
return (
<button
data-slot="question-option"
@@ -355,9 +381,9 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
<span data-slot="option-label">{localized.label()}</span>
<Show when={localized.description()}>
<span data-slot="option-description">{localized.description()}</span>
</Show>
</span>
</button>
@@ -439,7 +465,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
const answered = () => Boolean(value())
return (
<div data-slot="review-item">
<span data-slot="review-label">{q.question}</span>
<span data-slot="review-label">{tr(language.t, q.questionKey, q.question)}</span>
<span data-slot="review-value" data-answered={answered()}>
{answered() ? value() : language.t("ui.question.review.notAnswered")}
</span>
@@ -1,5 +1,25 @@
import type { QuestionOption } from "../../types/messages"
/**
* Translate a backend-provided i18n key, falling back to the canonical label when no key is
* set or the key is missing from the dictionary.
*
* Implementation detail: our `language.t` (see webview-ui/src/context/language.tsx) returns
* the key string back when a translation is missing. We detect that "echo" and substitute
* the fallback. If `language.t` ever changes to return `undefined` / empty / throw on miss,
* this helper's fallback branch must be updated to match.
*
* Edge case: if a translator ever writes a value identical to the dotted key string, this
* helper would falsely fall back to the English label. Namespaced keys (`plan.followup.*`)
* make this practically impossible, but keep it in mind before picking ambiguous key names.
*/
export function tr(translate: (key: string) => string, key: string | undefined, fallback: string): string {
if (!key) return fallback
const result = translate(key)
if (result === key) return fallback
return result
}
export type PickOutcome = { kind: "submit" } | { kind: "advance" } | { kind: "stay" }
/**
@@ -206,6 +206,10 @@ 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 {
@@ -214,6 +218,9 @@ export interface QuestionInfo {
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 {
@@ -265,7 +265,9 @@ export namespace PlanFollowup {
questions: [
{
question: "Ready to implement?",
questionKey: "plan.followup.question",
header: "Implement",
headerKey: "plan.followup.header",
// On CLI the main prompt input is hidden while a blocking question is active,
// so we need the custom-answer row to allow a free-text reply. On VS Code the
// main prompt input below the dock already routes typed text as a question
@@ -275,11 +277,15 @@ export namespace PlanFollowup {
options: [
{
label: ANSWER_NEW_SESSION,
labelKey: "plan.followup.answer.newSession",
description: "Implement in a fresh session with a clean context",
descriptionKey: "plan.followup.answer.newSession.description",
},
{
label: ANSWER_CONTINUE,
labelKey: "plan.followup.answer.continue",
description: "Implement the plan in this session",
descriptionKey: "plan.followup.answer.continue.description",
},
],
},
@@ -1,3 +1,4 @@
import type { AlibabaProviderOptions } from "@ai-sdk/alibaba"
import type { AnthropicProviderOptions } from "@ai-sdk/anthropic"
import type { OpenAIResponsesProviderOptions } from "@ai-sdk/openai"
import type { OpenAICompatibleProviderOptions } from "@ai-sdk/openai-compatible"
@@ -25,5 +26,8 @@ export function kiloProviderOptions(options: { [x: string]: any }) {
openrouter.reasoning && "effort" in openrouter.reasoning ? openrouter.reasoning?.effort : undefined,
textVerbosity: openrouter.verbosity,
} satisfies OpenAICompatibleProviderOptions
result.alibaba = {
enableThinking: openrouter.reasoning?.enabled,
} satisfies AlibabaProviderOptions
return result
}
@@ -147,6 +147,7 @@ export function kiloCustomLoaders(dep: CustomDep): Record<string, CustomLoader>
options,
async getModel(sdk: KiloProvider, modelID: string) {
const provider = input.models[modelID]?.ai_sdk_provider
if (provider === "alibaba") return sdk.alibaba(modelID)
if (provider === "anthropic") return sdk.anthropic(modelID)
if (provider === "openai") return sdk.openai(modelID)
if (provider === "openai-compatible") return sdk.openaiCompatible(modelID)
+17
View File
@@ -21,6 +21,15 @@ export namespace Question {
description: Schema.String.annotate({
description: "Explanation of choice",
}),
// kilocode_change start - optional i18n keys so clients can translate while still
// replying with the canonical English label (backend matches on `label`).
labelKey: Schema.optional(Schema.String).annotate({
description: "Optional i18n key for the label; clients translate and still reply with `label`",
}),
descriptionKey: Schema.optional(Schema.String).annotate({
description: "Optional i18n key for the description",
}),
// kilocode_change end
}) {
static readonly zod = zod(this)
}
@@ -38,6 +47,14 @@ export namespace Question {
multiple: Schema.optional(Schema.Boolean).annotate({
description: "Allow selecting multiple choices",
}),
// kilocode_change start - optional i18n keys for question text and header
questionKey: Schema.optional(Schema.String).annotate({
description: "Optional i18n key for the question text; clients fall back to `question` when missing",
}),
headerKey: Schema.optional(Schema.String).annotate({
description: "Optional i18n key for the header; clients fall back to `header` when missing",
}),
// kilocode_change end
}
export class Info extends Schema.Class<Info>("QuestionInfo")({
+5 -2
View File
@@ -60,8 +60,11 @@ const Parameters = z.object({
.optional(),
description: z
.string()
.optional() // kilocode_change
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
// kilocode_change start
"Recommended: a clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
// kilocode_change end
),
})
@@ -500,7 +503,7 @@ export const BashTool = Tool.define(
cwd,
env: yield* shellEnv(ctx, cwd),
timeout,
description: params.description,
description: params.description ?? params.command, // kilocode_change
},
ctx,
)

Some files were not shown because too many files have changed in this diff Show More