mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-29 18:52:45 +08:00
Merge remote-tracking branch 'origin/main' into fix/custom-provider-variant-validation
This commit is contained in:
@@ -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.
|
||||
@@ -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"`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Make the `description` parameter of the bash tool optional.
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+8
@@ -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": "نفّذ الخطة في هذه الجلسة",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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": "このセッションで計画を実装する",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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": "이 세션에서 계획 구현",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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": "Реализовать план в этой сессии",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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": "ดำเนินการตามแผนในเซสชันนี้",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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": "Реалізувати план у цій сесії",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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": "在本会话中实现计划",
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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": "在本工作階段中實作計畫",
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
+78
@@ -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")
|
||||
|
||||
|
||||
+219
-7
@@ -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)
|
||||
// ================================================================
|
||||
|
||||
+45
-1
@@ -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)
|
||||
}
|
||||
|
||||
+201
@@ -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",
|
||||
)
|
||||
}
|
||||
+377
@@ -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")
|
||||
|
||||
+3
-3
@@ -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 {
|
||||
|
||||
+36
-1
@@ -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) }
|
||||
}
|
||||
|
||||
+574
@@ -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
|
||||
}
|
||||
+32
@@ -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)
|
||||
}
|
||||
+52
-118
@@ -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
|
||||
}
|
||||
|
||||
+45
@@ -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,
|
||||
)
|
||||
+34
@@ -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,
|
||||
)
|
||||
+23
@@ -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,
|
||||
)
|
||||
-45
@@ -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)
|
||||
}
|
||||
+299
-303
@@ -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" }
|
||||
}
|
||||
|
||||
+52
@@ -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)
|
||||
}
|
||||
}
|
||||
+9
-112
@@ -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)
|
||||
|
||||
-165
@@ -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
|
||||
}
|
||||
}
|
||||
+279
@@ -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}"
|
||||
}
|
||||
}
|
||||
+15
-15
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+54
@@ -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)
|
||||
}
|
||||
}
|
||||
+43
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+129
@@ -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)
|
||||
}
|
||||
}
|
||||
+116
@@ -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"))
|
||||
}
|
||||
}
|
||||
+202
@@ -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"),
|
||||
)
|
||||
}
|
||||
+184
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+56
-15
@@ -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 ------
|
||||
+14
-6
@@ -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()
|
||||
|
||||
+266
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+43
@@ -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)
|
||||
}
|
||||
}
|
||||
+164
@@ -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)
|
||||
}
|
||||
}
|
||||
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
+43
@@ -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)
|
||||
}
|
||||
}
|
||||
-19
@@ -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)
|
||||
}
|
||||
}
|
||||
-41
@@ -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 })
|
||||
}
|
||||
}
|
||||
-31
@@ -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)
|
||||
}
|
||||
}
|
||||
-60
@@ -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 })
|
||||
}
|
||||
}
|
||||
-55
@@ -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"))
|
||||
}
|
||||
}
|
||||
+577
@@ -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())
|
||||
}
|
||||
}
|
||||
-44
@@ -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)
|
||||
}
|
||||
}
|
||||
-53
@@ -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)
|
||||
}
|
||||
}
|
||||
-26
@@ -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 })
|
||||
}
|
||||
}
|
||||
-32
@@ -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)
|
||||
}
|
||||
}
|
||||
+45
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+401
@@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+3
-45
@@ -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)
|
||||
}
|
||||
|
||||
-84
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
-1
@@ -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
|
||||
|
||||
+2
-12
@@ -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", () => {
|
||||
|
||||
+1
-1
@@ -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
|
||||
*/
|
||||
|
||||
+10
-26
@@ -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
|
||||
|
||||
+6
-260
@@ -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)
|
||||
|
||||
@@ -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")({
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user