feat(jetbrains): implement basic agent chat and refactor backend into cli/ package

Add basic chat functionality: session creation, mode/model/temperature
config, prompt sending with streaming SSE responses. Includes shared
DTOs, RPC interface extensions, backend chat manager, frontend service,
and Swing-based chat UI (message list, input panel, toolbar).

Refactor backend by creating cli/ package for CLI infrastructure
(CliServer, KiloBackendCliManager, KiloBackendHttpClients) and
introducing KiloCliDataParser — a stateless object that centralizes
all CLI response parsing. Callers pass raw JSON, get typed DTOs back.
Includes 30 test cases for the parser.
This commit is contained in:
kirillk
2026-04-14 15:41:13 -04:00
parent 5f39b745c3
commit 2daebbd1cb
23 changed files with 2502 additions and 147 deletions
+539
View File
@@ -0,0 +1,539 @@
# Basic Agent Chat for JetBrains Plugin
## Goal
Implement basic agent chat functionality: create sessions, change mode/model/temperature, send prompts and receive streaming responses. Tested in Ask mode, no permission handling.
---
# Backend Refactor: `backend/cli` Package + `KiloCliDataParser`
## Goal
1. Create `backend/cli/` package and move CLI-related infrastructure there
2. Centralize all CLI response parsing into a single `KiloCliDataParser` class — callers pass raw JSON, get typed DTOs back, no JSON knowledge leaks outside the parser
3. Make the parser extensively testable so every new parsing issue gets a test case
## What Moves
### To `backend/cli/` (new package)
| File | Current Location | Notes |
| --------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CliServer.kt` | `backend/app/` | Interface — no changes beyond package |
| `KiloBackendCliManager.kt` | `backend/app/` | Implementation — no changes beyond package |
| `KiloBackendHttpClients.kt` | `backend/util/` | HTTP client factory — belongs with CLI infra |
| `KiloCliDataParser.kt` | **new** | Central parser (see below) |
| `SseEvent.kt` | extract from `KiloBackendConnectionService.kt` | The `data class SseEvent` and `ConnectionState` sealed class stay in `app/` since they're connection-level, but `SseEvent` is also fine to move since it's CLI-level data |
### Stays in `backend/app/`
| File | Reason |
| --------------------------------- | ----------------------------------------------------------------- |
| `KiloBackendAppService.kt` | App lifecycle orchestrator — uses generated API client + parser |
| `KiloBackendConnectionService.kt` | SSE/connection management — uses parser for `extractType` |
| `KiloBackendChatManager.kt` | Chat orchestration — uses parser for SSE events + message history |
| `KiloBackendSessionManager.kt` | Session CRUD — uses parser for session creation + status events |
| `KiloAppState.kt` | State sealed class |
## `KiloCliDataParser` Design
A stateless object (no dependencies, no coroutines, no services) that owns ALL JSON-to-DTO conversion from CLI server responses. Every public method takes raw data in, returns a typed DTO out.
```kotlin
package ai.kilocode.backend.cli
object KiloCliDataParser {
// ------ SSE event parsing ------
/** Extract the event type from raw SSE JSON data (fallback when OkHttp type is null). */
fun extractEventType(data: String): String
/** Parse an SSE chat event into a ChatEventDto. Returns null if unrecognized/malformed. */
fun parseChatEvent(type: String, data: String): ChatEventDto?
/** Extract session status from an SSE session.status event. Returns (sessionID, StatusDto)? */
fun parseSessionStatus(data: String): Pair<String, SessionStatusDto>?
// ------ HTTP response parsing ------
/** Parse a session creation response (POST /session) into SessionDto. */
fun parseSession(raw: String): SessionDto
/** Parse message history response (GET /session/{id}/message) into messages+parts. */
fun parseMessages(raw: String): List<MessageWithPartsDto>
// ------ JSON serialization (DTO → JSON for outgoing requests) ------
/** Build the JSON body for POST /session/{id}/prompt_async. */
fun buildPromptJson(prompt: PromptDto): String
/** Build the partial JSON body for PATCH /global/config. */
fun buildConfigPartial(update: ConfigUpdateDto): String
}
```
### What gets consolidated
All these scattered parsing helpers merge into the parser:
| Current Location | Current Function | Destination |
| ------------------------------ | ------------------------------------ | ------------------------------------------- |
| `KiloBackendChatManager` | `parse(SseEvent)` | `parseChatEvent(type, data)` |
| `KiloBackendChatManager` | `parseMessages(raw)` | `parseMessages(raw)` |
| `KiloBackendChatManager` | `parseMessage(obj)` | private `parseMessage(obj)` |
| `KiloBackendChatManager` | `parsePart(obj)` | private `parsePart(obj)` |
| `KiloBackendChatManager` | `parseError(obj)` | private `parseError(obj)` |
| `KiloBackendChatManager` | `buildPromptJson(prompt)` | `buildPromptJson(prompt)` |
| `KiloBackendChatManager` | `buildConfigPartial(update)` | `buildConfigPartial(update)` |
| `KiloBackendChatManager` | `JsonObject.str/num/long` extensions | private extensions inside parser |
| `KiloBackendChatManager` | `jsonString(value)` | private `jsonString(value)` |
| `KiloBackendSessionManager` | `extractField(json, field)` | private `extractField(json, field)` |
| `KiloBackendSessionManager` | `extractNested(json, outer, inner)` | private `extractNested(json, outer, inner)` |
| `KiloBackendSessionManager` | `dtoFromJson(obj)` | `parseSession(raw)` |
| `KiloBackendSessionManager` | `handleStatus(data)` | `parseSessionStatus(data)` |
| `KiloBackendConnectionService` | `extractType(data)` | `extractEventType(data)` |
### What callers look like after refactor
**KiloBackendChatManager** (before):
```kotlin
private fun parse(event: SseEvent): ChatEventDto? {
val obj = json.parseToJsonElement(event.data).jsonObject
val payload = obj["payload"]?.jsonObject ?: obj
val props = payload["properties"]?.jsonObject ?: return null
// ... 60 lines of when/JsonObject navigation
}
```
**KiloBackendChatManager** (after):
```kotlin
// SSE watcher
sse.collect { event ->
if (event.type in CHAT_EVENTS) {
KiloCliDataParser.parseChatEvent(event.type, event.data)?.let { _events.emit(it) }
}
}
// Message history
fun messages(id: String, dir: String): List<MessageWithPartsDto> {
// ... HTTP call ...
val raw = response.body?.string() ?: return emptyList()
return KiloCliDataParser.parseMessages(raw)
}
// Prompt
val body = KiloCliDataParser.buildPromptJson(prompt)
```
**KiloBackendSessionManager** (after):
```kotlin
fun create(dir: String): SessionDto {
// ... HTTP call ...
val raw = response.body?.string()!!
return KiloCliDataParser.parseSession(raw)
}
// SSE status handling
private fun handleStatus(data: String) {
val (id, status) = KiloCliDataParser.parseSessionStatus(data) ?: return
_statuses.update { it + (id to status) }
}
```
**KiloBackendConnectionService** (after):
```kotlin
override fun onEvent(src: EventSource, id: String?, type: String?, data: String) {
val kind = type ?: KiloCliDataParser.extractEventType(data)
cs.launch { _events.emit(SseEvent(type = kind, data = data)) }
}
```
## Test Strategy
`KiloCliDataParserTest.kt` — pure unit tests with no mocks, no services, no coroutines. Just JSON in → DTO out.
```kotlin
class KiloCliDataParserTest {
// SSE events
@Test fun `parseChatEvent - message updated`()
@Test fun `parseChatEvent - message part delta`()
@Test fun `parseChatEvent - message part updated`()
@Test fun `parseChatEvent - turn open`()
@Test fun `parseChatEvent - turn close`()
@Test fun `parseChatEvent - session error`()
@Test fun `parseChatEvent - message removed`()
@Test fun `parseChatEvent - unknown type returns null`()
@Test fun `parseChatEvent - malformed JSON returns null`()
@Test fun `parseChatEvent - missing properties returns null`()
@Test fun `parseChatEvent - GlobalEvent wrapper with payload`()
@Test fun `parseChatEvent - flat event without payload wrapper`()
// Session status
@Test fun `parseSessionStatus - valid status event`()
@Test fun `parseSessionStatus - missing sessionID returns null`()
// Session creation
@Test fun `parseSession - full session response`()
@Test fun `parseSession - minimal session response`()
// Message history
@Test fun `parseMessages - empty array`()
@Test fun `parseMessages - user and assistant messages`()
@Test fun `parseMessages - message with text parts`()
@Test fun `parseMessages - message with tool parts`()
// JSON builders
@Test fun `buildPromptJson - text only`()
@Test fun `buildPromptJson - with model override`()
@Test fun `buildPromptJson - with agent`()
@Test fun `buildConfigPartial - model only`()
@Test fun `buildConfigPartial - agent and temperature`()
// Event type extraction
@Test fun `extractEventType - valid type`()
@Test fun `extractEventType - missing type returns unknown`()
}
```
Each test uses literal JSON strings as fixtures — easy to add a new test case when a parsing bug is discovered.
## Execution Order
1. Create `backend/cli/` package
2. Move `CliServer.kt``backend/cli/CliServer.kt` (update package)
3. Move `KiloBackendCliManager.kt``backend/cli/KiloBackendCliManager.kt` (update package + imports)
4. Move `KiloBackendHttpClients.kt``backend/cli/KiloBackendHttpClients.kt` (update package + imports)
5. Create `backend/cli/KiloCliDataParser.kt` — consolidate all parsing
6. Update `KiloBackendChatManager` — remove all parsing, delegate to `KiloCliDataParser`
7. Update `KiloBackendSessionManager` — remove `extractField/extractNested/dtoFromJson/handleStatus` parsing, delegate to `KiloCliDataParser`
8. Update `KiloBackendConnectionService` — remove `extractType`, delegate to `KiloCliDataParser`
9. Update all imports across backend (app service, workspace, rpc, tests)
10. Create `KiloCliDataParserTest.kt` with full test coverage
11. Update existing tests that reference moved classes
12. Verify build compiles + tests pass
## Architecture Overview
The flow mirrors the VS Code extension pattern:
```
Frontend (Swing UI) ←RPC→ Backend (services) ←HTTP/SSE→ CLI Backend (kilo serve)
```
Key difference from VS Code: JetBrains uses split-mode RPC instead of `postMessage`. SSE events already flow through `KiloBackendConnectionService.events: SharedFlow<SseEvent>` — we just need to subscribe to chat-related events and forward them over RPC flows.
## Data Flow
### Send Message Flow
```
Frontend: KiloSessionService.prompt(sessionID, text, model?, agent?)
↓ RPC
Backend: KiloBackendChatManager.prompt(sessionID, dir, parts, model?, agent?)
↓ HTTP POST /session/{id}/prompt_async (fire-and-forget, 204)
Server: SessionPrompt.prompt() → AI runtime → Bus events
↓ SSE via GET /global/event
Backend: SharedFlow<SseEvent> → KiloBackendChatManager parses & emits
↓ RPC Flow
Frontend: Collects Flow<ChatEventDto> → updates UI
```
### Config Update Flow (mode/model/temperature)
```
Frontend: KiloSessionService.updateConfig(config)
↓ RPC
Backend: KiloBackendChatManager.updateConfig(dir, config)
↓ HTTP PATCH /config
Server: Updates config → emits global.config.updated SSE
↓ Already handled in KiloBackendAppService
Backend: Config reloaded automatically
```
## Implementation Plan
### Phase 1: Shared DTOs (in `shared/src/main/kotlin/ai/kilocode/rpc/dto/`)
#### 1.1 `ChatDto.kt` — Message & Part DTOs for RPC transport
These are simplified DTOs for the basic chat use case. We don't model the full Part union — just the types we need for a basic Ask-mode chat.
```kotlin
// --- Messages ---
@Serializable
data class MessageDto(
val id: String,
val sessionID: String,
val role: String, // "user" | "assistant"
val time: MessageTimeDto,
val agent: String? = null,
val providerID: String? = null,
val modelID: String? = null,
val parentID: String? = null, // assistant only
val cost: Double? = null, // assistant only
val tokens: TokensDto? = null, // assistant only
val error: MessageErrorDto? = null,
)
@Serializable
data class MessageTimeDto(
val created: Double,
val completed: Double? = null,
)
@Serializable
data class TokensDto(
val input: Long,
val output: Long,
val reasoning: Long,
val cacheRead: Long,
val cacheWrite: Long,
)
@Serializable
data class MessageErrorDto(
val type: String, // "provider_auth", "api", "unknown", etc.
val message: String? = null,
)
@Serializable
data class MessageWithPartsDto(
val info: MessageDto,
val parts: List<PartDto>,
)
// --- Parts (simplified for basic chat) ---
@Serializable
data class PartDto(
val id: String,
val sessionID: String,
val messageID: String,
val type: String, // "text", "tool", "reasoning", "step-start", "step-finish", etc.
val text: String? = null, // text & reasoning parts
val tool: String? = null, // tool parts
val state: String? = null, // tool state: "pending", "running", "completed", "error"
val title: String? = null, // tool title
)
// --- Prompt Input ---
@Serializable
data class PromptDto(
val parts: List<PromptPartDto>,
val providerID: String? = null,
val modelID: String? = null,
val agent: String? = null,
)
@Serializable
data class PromptPartDto(
val type: String, // "text"
val text: String,
)
// --- Streaming Events ---
@Serializable
sealed class ChatEventDto {
@Serializable
data class MessageUpdated(
val sessionID: String,
val info: MessageDto,
) : ChatEventDto()
@Serializable
data class PartUpdated(
val sessionID: String,
val part: PartDto,
) : ChatEventDto()
@Serializable
data class PartDelta(
val sessionID: String,
val messageID: String,
val partID: String,
val field: String,
val delta: String,
) : ChatEventDto()
@Serializable
data class TurnOpen(
val sessionID: String,
) : ChatEventDto()
@Serializable
data class TurnClose(
val sessionID: String,
val reason: String, // "completed", "error", "interrupted"
) : ChatEventDto()
@Serializable
data class Error(
val sessionID: String?,
val error: MessageErrorDto? = null,
) : ChatEventDto()
@Serializable
data class MessageRemoved(
val sessionID: String,
val messageID: String,
) : ChatEventDto()
}
// --- Config Update ---
@Serializable
data class ConfigUpdateDto(
val model: String? = null, // "provider/model" format
val agent: String? = null, // default agent name
val temperature: Double? = null, // temperature for the agent
)
```
### Phase 2: RPC Interface Extensions (in `shared/`)
#### 2.1 Add chat methods to `KiloSessionRpcApi.kt`
```kotlin
// Add to existing KiloSessionRpcApi:
/** Send a prompt (fire-and-forget). */
suspend fun prompt(id: String, directory: String, prompt: PromptDto)
/** Abort ongoing processing for a session. */
suspend fun abort(id: String, directory: String)
/** Load message history for a session. */
suspend fun messages(id: String, directory: String): List<MessageWithPartsDto>
/** Subscribe to chat events for a specific session. */
suspend fun events(id: String, directory: String): Flow<ChatEventDto>
/** Update config (model, agent/mode, temperature). */
suspend fun updateConfig(directory: String, config: ConfigUpdateDto)
```
### Phase 3: Backend Chat Manager (in `backend/`)
#### 3.1 `KiloBackendChatManager.kt` — New class owned by `KiloBackendAppService`
Responsibilities:
- Calls generated API client for `promptAsync`, `sessionMessages`, `sessionAbort`, `configUpdate`
- Subscribes to SSE `SharedFlow<SseEvent>` and parses chat-relevant events
- Exposes per-session `Flow<ChatEventDto>` for the frontend
- Maps generated API model types to RPC DTOs
**SSE events to handle:**
| SSE Event Type | → ChatEventDto |
|---|---|
| `message.updated` | `ChatEventDto.MessageUpdated` |
| `message.part.updated` | `ChatEventDto.PartUpdated` |
| `message.part.delta` | `ChatEventDto.PartDelta` |
| `message.removed` | `ChatEventDto.MessageRemoved` |
| `session.turn.open` | `ChatEventDto.TurnOpen` |
| `session.turn.close` | `ChatEventDto.TurnClose` |
| `session.error` | `ChatEventDto.Error` |
**Event parsing approach:** The SSE data arrives as raw JSON strings through `SharedFlow<SseEvent>`. Since the generated OpenAPI models use `anyOf` mapped to `kotlin.Any`, we parse the relevant fields using the same regex extraction approach already used in `KiloBackendSessionManager.extractField()`. For the basic chat, we only need a few fields from each event.
**Config update approach:** The CLI API's `PATCH /config` accepts the full `Config` object. Since we only need to change model/agent/temperature, we:
1. Read current config from `KiloBackendAppService.config`
2. Apply the delta (model, default_agent, agent temperature)
3. Send the full config via the generated client's `configUpdate()`
The `global.config.updated` SSE event is already handled by `KiloBackendAppService.startWatchingGlobalSseEvents()`, which re-fetches and updates `appState`.
#### 3.2 Wire into `KiloBackendAppService`
```kotlin
// In KiloBackendAppService:
val chat = KiloBackendChatManager(cs, log)
// In load(), after sessions.start() and workspaces.start():
chat.start(connection.api!!, connection.events)
// In clear():
chat.stop()
```
### Phase 4: RPC Implementation (in `backend/rpc/`)
#### 4.1 Update `KiloSessionRpcApiImpl.kt`
Add implementations for the new methods that delegate to `KiloBackendChatManager`.
### Phase 5: Frontend Service (in `frontend/`)
#### 5.1 Update `KiloSessionService.kt`
Add methods that call the new RPC endpoints:
- `prompt(sessionID, text, model?, agent?)` — send a message
- `abort(sessionID)` — cancel processing
- `messages(sessionID)` — load history
- `events(sessionID)` — subscribe to streaming events
- `updateConfig(config)` — change mode/model/temperature
These will be called by the UI (Phase 6).
### Phase 6: Basic Chat UI (in `frontend/`)
A Swing-based tool window panel for chat. Minimal viable UI:
1. **Message list** — scrollable panel showing user/assistant messages with streaming text
2. **Input area** — text field + send button at the bottom
3. **Toolbar** — mode selector (dropdown), model selector (dropdown), temperature input
4. **Status** — show session status (idle/busy) and abort button
Uses standard IntelliJ Platform components (per AGENTS.md — no Compose, no JCEF):
- `JBScrollPane` for message list
- `JBTextArea` for input
- `JBList` or custom panel for messages
- `ComboBox` for mode/model dropdowns
- Action system for abort
## File Changes Summary
| File | Change |
| ------------------------------------------- | ------------------------------------------------------------- |
| `shared/.../dto/ChatDto.kt` | **New** — Message, Part, Prompt, ChatEvent, ConfigUpdate DTOs |
| `shared/.../KiloSessionRpcApi.kt` | **Edit** — Add 5 chat methods |
| `backend/.../app/KiloBackendChatManager.kt` | **New** — Chat orchestration, SSE→DTO mapping, API calls |
| `backend/.../app/KiloBackendAppService.kt` | **Edit** — Wire chat manager lifecycle |
| `backend/.../rpc/KiloSessionRpcApiImpl.kt` | **Edit** — Implement new chat RPC methods |
| `frontend/.../KiloSessionService.kt` | **Edit** — Add chat RPC calls |
| `frontend/.../chat/ChatPanel.kt` | **New** — Main chat UI panel |
| `frontend/.../chat/MessageListPanel.kt` | **New** — Scrollable message display |
| `frontend/.../chat/ChatInputPanel.kt` | **New** — Text input + send |
| `frontend/.../chat/ChatToolbar.kt` | **New** — Mode/model/temperature controls |
| `frontend/.../KiloToolWindowFactory.kt` | **Edit** — Wire chat panel into tool window |
## Testing Plan
- **Unit tests** for `KiloBackendChatManager`: SSE event parsing, DTO mapping, prompt dispatch
- **Extend `MockCliServer`** to simulate `POST /session/{id}/prompt_async` (204) and SSE chat events
- **Manual test**: Run `./gradlew runIde`, open a project, select Ask mode, type a prompt, verify streaming response
## Key Design Decisions
1. **Flat PartDto instead of sealed hierarchy** — For the basic chat, a single `PartDto` with a `type` discriminator and optional fields is simpler than modeling the full 12-variant Part union. The UI only needs `type`, `text`, `tool`, `state`, `title` for now. Can be refined later.
2. **Regex JSON parsing for SSE events** — The existing pattern in `KiloBackendSessionManager.extractField()` works well for extracting known fields from SSE JSON. Avoids dependency on the generated models' `anyOf → kotlin.Any` type mappings which don't deserialize cleanly.
3. **Per-session event flow via RPC** — The frontend subscribes to `events(sessionID)` which returns a `Flow<ChatEventDto>`. The backend filters the global SSE stream by sessionID and maps to DTOs. This keeps the frontend simple and the RPC boundary clean.
4. **Config update via full PATCH** — Read current config, apply delta, send full object. The SSE `global.config.updated` event already triggers a reload in `KiloBackendAppService`, so the frontend gets the update automatically.
5. **No permission handling** — Skipped for v1. Tool calls will show status but permission requests won't be forwarded to the UI.
@@ -1,5 +1,7 @@
package ai.kilocode.backend.app
import ai.kilocode.backend.cli.CliServer
import ai.kilocode.backend.cli.KiloBackendCliManager
import ai.kilocode.backend.util.IntellijLog
import ai.kilocode.backend.util.KiloLog
import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager
@@ -26,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import okhttp3.OkHttpClient
import kotlinx.coroutines.sync.withLock
import java.net.ConnectException
import java.net.SocketTimeoutException
@@ -89,8 +92,11 @@ class KiloBackendAppService private constructor(
val events: SharedFlow<SseEvent> get() = connection.events
val api: DefaultApi? get() = connection.api
val http: OkHttpClient? get() = connection.apiClient
val port: Int get() = connection.port
val sessions = KiloBackendSessionManager(cs, log)
val chat = KiloBackendChatManager(cs, log)
val workspaces = KiloBackendWorkspaceManager(cs, sessions, log)
@Volatile var profile: KiloProfile200Response? = null
@@ -230,7 +236,8 @@ class KiloBackendAppService private constructor(
profile = prof
config = cfg
notifications = notifs
sessions.start(connection.api!!, connection.events)
sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events)
chat.start(connection.apiClient!!, connection.port, connection.events)
workspaces.start(connection.api!!, connection.events)
_appState.value = KiloAppState.Ready(
AppData(
@@ -256,8 +263,12 @@ class KiloBackendAppService private constructor(
/**
* Fetch the user profile. Returns [FetchResult.ok] with the response
* on success, [FetchResult.ok] with `null` when not logged in (401),
* or [FetchResult.fail] on other errors. Never throws.
* on success, [FetchResult.ok] with `null` when not logged in or when
* the server cannot reach the profile endpoint. Never throws.
*
* Profile is optional — 401 (not logged in) and 5xx (gateway/network
* errors) are both non-fatal. Only unexpected client errors are treated
* as failures.
*/
private suspend fun fetchProfile(): FetchResult<KiloProfile200Response?> {
val client = connection.api
@@ -274,6 +285,12 @@ class KiloBackendAppService private constructor(
log.warn("Profile fetch failed: HTTP ${e.statusCode}", e)
logResponseBody("profile", e)
FetchResult.fail("profile", e)
} catch (e: ServerException) {
// 5xx from the CLI — profile endpoint is unreachable (no auth,
// gateway down, etc.). Treat the same as not-logged-in.
log.warn("Profile fetch: server error (${e.statusCode}) — treating as unavailable", e)
logResponseBody("profile", e)
FetchResult.ok(null)
} catch (e: Exception) {
log.warn("Profile fetch failed: ${e.message}", e)
logResponseBody("profile", e)
@@ -399,6 +416,7 @@ class KiloBackendAppService private constructor(
eventWatcher?.cancel()
}
workspaces.stop()
chat.stop()
sessions.stop()
profile = null
config = null
@@ -0,0 +1,191 @@
package ai.kilocode.backend.app
import ai.kilocode.backend.cli.KiloCliDataParser
import ai.kilocode.backend.util.KiloLog
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PromptDto
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Chat orchestrator that handles message sending, history loading,
* and SSE event routing for the agent chat UI.
*
* **Not an IntelliJ service** — owned by [KiloBackendAppService] which
* calls [start] after [KiloAppState.Ready] and [stop] on disconnect.
*
* All JSON parsing is delegated to [KiloCliDataParser].
*/
class KiloBackendChatManager(
private val cs: CoroutineScope,
private val log: KiloLog,
) {
companion object {
private val JSON_TYPE = "application/json".toMediaType()
private val CHAT_EVENTS = setOf(
"message.updated",
"message.removed",
"message.part.updated",
"message.part.delta",
"session.turn.open",
"session.turn.close",
"session.error",
)
}
private val _events = MutableSharedFlow<ChatEventDto>(extraBufferCapacity = 128)
val events: SharedFlow<ChatEventDto> = _events.asSharedFlow()
private var client: OkHttpClient? = null
private var base: String? = null
private var watcher: Job? = null
fun start(http: OkHttpClient, port: Int, sse: SharedFlow<SseEvent>) {
client = http
base = "http://127.0.0.1:$port"
if (watcher?.isActive == true) return
watcher = cs.launch {
sse.collect { event ->
if (event.type in CHAT_EVENTS) {
log.info("SSE chat event: type=${event.type}, data=${event.data.take(200)}")
val parsed = KiloCliDataParser.parseChatEvent(event.type, event.data)
if (parsed != null) {
log.info("SSE parsed → ${parsed::class.simpleName}")
_events.emit(parsed)
} else {
log.warn("SSE parse returned null for type=${event.type}")
}
}
}
}
log.info("Chat manager started")
}
fun stop() {
watcher?.cancel()
watcher = null
client = null
base = null
log.info("Chat manager stopped")
}
// ------ prompt ------
fun prompt(id: String, dir: String, prompt: PromptDto) {
log.info("prompt: session=$id, dir=$dir, parts=${prompt.parts.size}, agent=${prompt.agent}, model=${prompt.providerID}/${prompt.modelID}")
val http = requireClient()
val url = requireBase()
val body = KiloCliDataParser.buildPromptJson(prompt)
log.info("prompt: request body=$body")
val target = "$url/session/$id/prompt_async?directory=${encode(dir)}"
log.info("prompt: POST $target")
val request = Request.Builder()
.url(target)
.post(body.toRequestBody(JSON_TYPE))
.build()
try {
http.newCall(request).execute().use { response ->
val code = response.code
val raw = response.body?.string()
log.info("prompt: response HTTP $code, body=${raw?.take(200)}")
if (!response.isSuccessful) {
log.warn("prompt_async failed: HTTP $code$raw")
throw RuntimeException("prompt_async failed: HTTP $code")
}
log.info("prompt: success (HTTP $code)")
}
} catch (e: RuntimeException) {
throw e
} catch (e: Exception) {
log.warn("prompt: HTTP call threw exception", e)
throw RuntimeException("prompt_async HTTP call failed: ${e.message}", e)
}
}
// ------ abort ------
fun abort(id: String, dir: String) {
val http = requireClient()
val url = requireBase()
val request = Request.Builder()
.url("$url/session/$id/abort?directory=${encode(dir)}")
.post("".toRequestBody(JSON_TYPE))
.build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
log.warn("abort failed: HTTP ${response.code}")
}
}
}
// ------ messages ------
fun messages(id: String, dir: String): List<MessageWithPartsDto> {
val http = requireClient()
val url = requireBase()
val request = Request.Builder()
.url("$url/session/$id/message?directory=${encode(dir)}")
.get()
.build()
return http.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
log.warn("messages failed: HTTP ${response.code}")
return emptyList()
}
val raw = response.body?.string() ?: return emptyList()
KiloCliDataParser.parseMessages(raw)
}
}
// ------ config update ------
fun updateConfig(dir: String, update: ConfigUpdateDto) {
val http = requireClient()
val url = requireBase()
val partial = KiloCliDataParser.buildConfigPartial(update)
log.info("config update: PATCH /global/config body=$partial")
val request = Request.Builder()
.url("$url/global/config")
.patch(partial.toRequestBody(JSON_TYPE))
.build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
val msg = response.body?.string() ?: "unknown error"
log.warn("config update failed: HTTP ${response.code}$msg")
} else {
log.info("Config updated: model=${update.model}, agent=${update.agent}, temp=${update.temperature}")
}
}
}
// ------ utilities ------
private fun requireClient(): OkHttpClient =
client ?: throw IllegalStateException("Chat manager not started")
private fun requireBase(): String =
base ?: throw IllegalStateException("Chat manager not started")
private fun encode(value: String): String =
java.net.URLEncoder.encode(value, "UTF-8")
}
@@ -1,7 +1,9 @@
package ai.kilocode.backend.app
import ai.kilocode.backend.cli.KiloBackendHttpClients
import ai.kilocode.backend.cli.KiloCliDataParser
import ai.kilocode.backend.cli.CliServer
import ai.kilocode.backend.util.IntellijLog
import ai.kilocode.backend.util.KiloBackendHttpClients
import ai.kilocode.backend.util.KiloLog
import ai.kilocode.jetbrains.api.client.DefaultApi
import kotlinx.coroutines.CoroutineScope
@@ -64,7 +66,6 @@ class KiloConnectionService(
private const val HEARTBEAT_TIMEOUT_MS = 15_000L
private const val HEALTH_POLL_INTERVAL_MS = 10_000L
private const val RECONNECT_DELAY_MS = 250L
private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""")
}
private val _state = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
@@ -77,9 +78,13 @@ class KiloConnectionService(
var api: DefaultApi? = null
private set
private var apiClient: OkHttpClient? = null
/** OkHttp client used for API calls — no call/read timeout. Null when disconnected. */
var apiClient: OkHttpClient? = null
private set
private var healthClient: OkHttpClient? = null
private var port = 0
/** Port the CLI server is listening on. Zero when disconnected. */
var port = 0
private set
private var password = ""
private val source = AtomicReference<EventSource?>(null)
@@ -214,7 +219,7 @@ class KiloConnectionService(
override fun onEvent(src: EventSource, id: String?, type: String?, data: String) {
lastEvent.set(System.currentTimeMillis())
val kind = type ?: extractType(data)
val kind = type ?: KiloCliDataParser.extractEventType(data)
cs.launch { _events.emit(SseEvent(type = kind, data = data)) }
}
@@ -327,9 +332,6 @@ class KiloConnectionService(
_state.value = next
}
internal fun extractType(data: String): String =
TYPE_REGEX.find(data)?.groupValues?.get(1) ?: "unknown"
fun dispose() {
disposed = true
source.getAndSet(null)?.cancel()
@@ -1,5 +1,6 @@
package ai.kilocode.backend.app
import ai.kilocode.backend.cli.KiloCliDataParser
import ai.kilocode.backend.util.KiloLog
import ai.kilocode.jetbrains.api.client.DefaultApi
import ai.kilocode.jetbrains.api.model.SessionStatus
@@ -16,6 +17,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.ConcurrentHashMap
/**
@@ -29,30 +34,13 @@ import java.util.concurrent.ConcurrentHashMap
*
* SSE `session.status` events are consumed directly from the events
* flow passed to [start], keeping the live [statuses] map current.
*
* All raw JSON parsing is delegated to [KiloCliDataParser].
*/
class KiloBackendSessionManager(
private val cs: CoroutineScope,
private val log: KiloLog,
) {
companion object {
private val FIELD_RE = ConcurrentHashMap<String, Regex>()
/** Extract a top-level string field from JSON without a full parser. */
internal fun extractField(json: String, field: String): String? {
val re = FIELD_RE.getOrPut(field) {
Regex(""""$field"\s*:\s*"([^"]+)"""")
}
return re.find(json)?.groupValues?.get(1)
}
/** Extract a string field nested one level deep. */
internal fun extractNested(json: String, outer: String, inner: String): String? {
val block = Regex(""""$outer"\s*:\s*\{([^}]+)}""")
.find(json)?.groupValues?.get(1) ?: return null
return extractField("{$block}", inner)
}
}
/** Per-session directory overrides (sessionId → worktree path). */
private val directories = ConcurrentHashMap<String, String>()
@@ -60,32 +48,34 @@ class KiloBackendSessionManager(
val statuses: StateFlow<Map<String, SessionStatusDto>> = _statuses.asStateFlow()
private var client: DefaultApi? = null
private var http: OkHttpClient? = null
private var base: String? = null
private var watcher: Job? = null
/**
* Activate the session manager with a connected API client and SSE stream.
* Called by [KiloBackendAppService] after [KiloAppState.Ready].
*/
fun start(api: DefaultApi, events: SharedFlow<SseEvent>) {
fun start(api: DefaultApi, httpClient: OkHttpClient, port: Int, events: SharedFlow<SseEvent>) {
client = api
http = httpClient
base = "http://127.0.0.1:$port"
if (watcher?.isActive == true) return
watcher = cs.launch {
events.collect { event ->
if (event.type == "session.status") {
handleStatus(event.data)
val pair = KiloCliDataParser.parseSessionStatus(event.data)
if (pair != null) {
_statuses.update { it + pair }
}
}
}
}
log.info("Session manager started")
}
/**
* Deactivate the session manager. Called by [KiloBackendAppService] on disconnect.
*/
fun stop() {
watcher?.cancel()
watcher = null
client = null
http = null
base = null
_statuses.value = emptyMap()
log.info("Session manager stopped")
}
@@ -95,7 +85,6 @@ class KiloBackendSessionManager(
// ------ session CRUD ------
/** List root sessions for a directory and include current statuses. */
fun list(dir: String): SessionListDto {
seed(dir)
val raw = requireClient().sessionList(directory = dir, roots = true)
@@ -105,16 +94,34 @@ class KiloBackendSessionManager(
return SessionListDto(mapped, relevant)
}
/** Create a new session in the given directory. */
fun create(dir: String): SessionDto =
dto(requireClient().sessionCreate(directory = dir))
/**
* Get a single session by ID.
* Create a new session in the given directory.
*
* Uses the session list endpoint and filters by ID since
* [DefaultApi] does not expose the single-session GET.
* Uses raw HTTP because the generated client sends malformed JSON
* for the optional request body (Content-Type set but empty body).
*/
fun create(dir: String): SessionDto {
val h = http ?: throw IllegalStateException("Session manager not started")
val url = base ?: throw IllegalStateException("Session manager not started")
val encoded = java.net.URLEncoder.encode(dir, "UTF-8")
log.info("Creating session: POST $url/session?directory=$encoded")
val request = Request.Builder()
.url("$url/session?directory=$encoded")
.post("{}".toRequestBody("application/json".toMediaType()))
.build()
h.newCall(request).execute().use { response ->
val raw = response.body?.string()
if (!response.isSuccessful) {
log.warn("Session create failed: HTTP ${response.code}, body=$raw")
throw RuntimeException("Session create failed: HTTP ${response.code}$raw")
}
log.info("Session created: HTTP ${response.code}")
return KiloCliDataParser.parseSession(raw!!)
}
}
fun get(id: String, dir: String): SessionDto {
val all = requireClient().sessionList(directory = dir)
val raw = all.firstOrNull { it.id == id }
@@ -122,13 +129,11 @@ class KiloBackendSessionManager(
return dto(raw)
}
/** Delete a session. */
fun delete(id: String, dir: String) {
requireClient().sessionDelete(sessionID = id, directory = dir)
directories.remove(id)
}
/** Seed status map from the server for a specific directory. */
fun seed(dir: String) {
try {
val raw = requireClient().sessionStatus(directory = dir)
@@ -149,16 +154,7 @@ class KiloBackendSessionManager(
fun getDirectory(id: String, fallback: String): String =
directories[id] ?: fallback
// ------ SSE event handling ------
private fun handleStatus(data: String) {
val id = extractField(data, "sessionID") ?: return
val type = extractNested(data, "status", "type") ?: "idle"
val msg = extractNested(data, "status", "message")
_statuses.update { it + (id to SessionStatusDto(type, msg)) }
}
// ------ mapping ------
// ------ mapping (generated API model → DTO) ------
private fun dto(s: ai.kilocode.jetbrains.api.model.Session) = SessionDto(
id = s.id,
@@ -1,4 +1,4 @@
package ai.kilocode.backend.app
package ai.kilocode.backend.cli
/**
* Abstraction over the CLI process lifecycle.
@@ -1,4 +1,4 @@
package ai.kilocode.backend.app
package ai.kilocode.backend.cli
import ai.kilocode.backend.util.IntellijLog
import ai.kilocode.backend.util.KiloLog
@@ -43,21 +43,11 @@ class KiloBackendCliManager(
private var process: Process? = null
private var hook: Thread? = null
/**
* When true, the next [extractCli] call deletes and re-extracts the binary
* regardless of the size check. Reset to false after extraction.
*/
@Volatile
override var forceExtract = false
override fun process(): Process? = process
/**
* Extract the CLI binary (if needed) and spawn `kilo serve`.
*
* Must be called under [KiloBackendAppService]'s mutex no internal
* synchronization is performed.
*/
override suspend fun init(): CliServer.State {
return try {
val path = extractCli()
@@ -67,8 +57,6 @@ class KiloBackendCliManager(
}
} catch (e: Exception) {
log.warn("CLI startup failed", e)
// If spawn started a process but timed out (or failed after start),
// kill the orphaned process so it doesn't leak.
process?.let { proc ->
log.info("Cleaning up orphaned CLI process (pid=${proc.pid()})")
process = null
@@ -82,19 +70,12 @@ class KiloBackendCliManager(
}
}
/**
* Mark the given process as exited and clear state.
* Called from the process monitor when the CLI process dies.
*/
override fun exited(proc: Process) {
if (process != proc) return
process = null
uninstall()
}
/**
* Kill the running CLI process and reset state so the next [init] spawns fresh.
*/
override fun stop() {
val proc = process ?: return
process = null
@@ -209,38 +190,27 @@ class KiloBackendCliManager(
val proc = process ?: return
process = null
uninstall()
kill(proc, "Disposing")
}
private fun install(proc: Process) {
uninstall()
val next = Thread({
log.info("Shutdown hook — killing CLI process tree (pid ${proc.pid()})")
kill(proc, "Shutdown hook", wait = false)
}, "kilo-cli-shutdown")
val ok = runCatching {
Runtime.getRuntime().addShutdownHook(next)
}
val ok = runCatching { Runtime.getRuntime().addShutdownHook(next) }
if (ok.isFailure) {
log.warn("Failed to install CLI shutdown hook", ok.exceptionOrNull())
return
}
hook = next
}
private fun uninstall() {
val curr = hook ?: return
hook = null
val ok = runCatching {
Runtime.getRuntime().removeShutdownHook(curr)
}
val ok = runCatching { Runtime.getRuntime().removeShutdownHook(curr) }
if (ok.isFailure) {
log.info("Skipping CLI shutdown hook removal: ${ok.exceptionOrNull()?.message}")
}
@@ -250,9 +220,7 @@ class KiloBackendCliManager(
log.info("$source — killing CLI process tree (pid ${proc.pid()})")
children(proc).forEach { it.destroy() }
proc.destroy()
if (!wait) return
if (!proc.waitFor(KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
log.warn("CLI process did not exit after SIGTERM, sending SIGKILL")
children(proc).forEach { it.destroyForcibly() }
@@ -260,22 +228,15 @@ class KiloBackendCliManager(
}
}
private fun children(proc: Process): List<ProcessHandle> {
return proc.toHandle().descendants().toList().asReversed()
}
private fun children(proc: Process): List<ProcessHandle> =
proc.toHandle().descendants().toList().asReversed()
private fun platform(): String {
val os = when {
SystemInfo.isMac -> "darwin"
SystemInfo.isLinux -> "linux"
SystemInfo.isWindows -> "windows"
else -> throw IllegalStateException(
"Unsupported OS: ${
System.getProperty(
"os.name"
)
}"
)
else -> throw IllegalStateException("Unsupported OS: ${System.getProperty("os.name")}")
}
val arch = when (CpuArch.CURRENT) {
CpuArch.ARM64 -> "arm64"
@@ -285,11 +246,6 @@ class KiloBackendCliManager(
return "$os-$arch"
}
/**
* Collect IDE-specific env vars for telemetry and gateway attribution.
* Catches all exceptions since these are best-effort missing values
* won't prevent the CLI from starting.
*/
private fun ideEnv(): Map<String, String> = buildMap {
runCatching {
val info = ApplicationInfo.getInstance()
@@ -310,10 +266,6 @@ class KiloBackendCliManager(
}.onFailure { log.info("Could not read machine ID: ${it.message}") }
}
/**
* Persistent machine ID stored in the IntelliJ system directory.
* Generated once and reused across restarts.
*/
private fun machineId(): String {
val file = File(PathManager.getSystemPath(), "kilo/machine-id")
if (file.exists()) return file.readText().trim()
@@ -1,4 +1,4 @@
package ai.kilocode.backend.util
package ai.kilocode.backend.cli
import okhttp3.ConnectionPool
import okhttp3.Interceptor
@@ -0,0 +1,341 @@
package ai.kilocode.backend.cli
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageErrorDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionStatusDto
import ai.kilocode.rpc.dto.SessionSummaryDto
import ai.kilocode.rpc.dto.SessionTimeDto
import ai.kilocode.rpc.dto.TokensDto
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import java.util.concurrent.ConcurrentHashMap
/**
* Stateless parser that centralizes all CLI server response parsing.
*
* Callers pass raw JSON strings (SSE event data, HTTP response bodies)
* and get typed DTOs back. All JSON navigation, regex extraction, and
* manual serialization is contained here — no caller needs to know
* about [JsonObject] or kotlinx.serialization.
*
* Designed for testability: every public method is pure (no side effects,
* no dependencies). When a new parsing bug is found, add a test case
* with the raw JSON that caused the issue.
*/
object KiloCliDataParser {
private val json = Json { ignoreUnknownKeys = true }
private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""")
private val FIELD_RE = ConcurrentHashMap<String, Regex>()
// ================================================================
// SSE event parsing
// ================================================================
/**
* Extract the event type from raw SSE JSON data.
* Used as a fallback when OkHttp's EventSourceListener receives a null type.
*/
fun extractEventType(data: String): String =
TYPE_REGEX.find(data)?.groupValues?.get(1) ?: "unknown"
/**
* Parse an SSE chat event into a [ChatEventDto].
* Returns null if the event type is unrecognized or the JSON is malformed.
*
* Handles the GlobalEvent wrapper: `{ directory, payload: { type, properties } }`
* as well as flat events with top-level `properties`.
*/
fun parseChatEvent(type: String, data: String): ChatEventDto? {
val obj = tryParseObject(data) ?: return null
// SSE data is a GlobalEvent: { directory, payload: { type, properties } }
val payload = obj["payload"]?.jsonObject ?: obj
val props = payload["properties"]?.jsonObject ?: return null
return when (type) {
"message.updated" -> {
val sid = props.str("sessionID") ?: return null
val info = props["info"]?.jsonObject ?: return null
ChatEventDto.MessageUpdated(sid, parseMessage(info))
}
"message.removed" -> {
val sid = props.str("sessionID") ?: return null
val mid = props.str("messageID") ?: return null
ChatEventDto.MessageRemoved(sid, mid)
}
"message.part.updated" -> {
val sid = props.str("sessionID") ?: return null
val part = props["part"]?.jsonObject ?: return null
ChatEventDto.PartUpdated(sid, parsePart(part))
}
"message.part.delta" -> {
val sid = props.str("sessionID") ?: return null
val mid = props.str("messageID") ?: return null
val pid = props.str("partID") ?: return null
val field = props.str("field") ?: return null
val delta = props.str("delta") ?: return null
ChatEventDto.PartDelta(sid, mid, pid, field, delta)
}
"session.turn.open" -> {
val sid = props.str("sessionID") ?: return null
ChatEventDto.TurnOpen(sid)
}
"session.turn.close" -> {
val sid = props.str("sessionID") ?: return null
val reason = props.str("reason") ?: "completed"
ChatEventDto.TurnClose(sid, reason)
}
"session.error" -> {
val sid = props.str("sessionID")
val err = props["error"]?.jsonObject?.let { parseError(it) }
ChatEventDto.Error(sid, err)
}
else -> null
}
}
/**
* Parse an SSE `session.status` event into a (sessionID, [SessionStatusDto]) pair.
* Returns null if the required fields are missing.
*
* Uses regex extraction (no full JSON parse) for consistency with
* the existing high-throughput status event handling.
*/
fun parseSessionStatus(data: String): Pair<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)
}
// ================================================================
// HTTP response parsing
// ================================================================
/**
* Parse a session creation response (`POST /session`) into [SessionDto].
*/
fun parseSession(raw: String): SessionDto {
val obj = json.parseToJsonElement(raw).jsonObject
return parseSessionObject(obj)
}
/**
* Parse message history response (`GET /session/{id}/message`)
* into a list of messages with their parts.
*/
fun parseMessages(raw: String): List<MessageWithPartsDto> {
val arr = tryParseArray(raw) ?: return emptyList()
return arr.mapNotNull { elem ->
val obj = elem.jsonObject
val info = obj["info"]?.jsonObject ?: return@mapNotNull null
val parts = obj["parts"]?.jsonArray ?: JsonArray(emptyList())
MessageWithPartsDto(
info = parseMessage(info),
parts = parts.map { parsePart(it.jsonObject) },
)
}
}
// ================================================================
// JSON serialization (DTO → JSON for outgoing requests)
// ================================================================
/**
* Build the JSON body for `POST /session/{id}/prompt_async`.
*/
fun buildPromptJson(prompt: PromptDto): String {
val parts = prompt.parts.joinToString(",") { part ->
"""{"type":"${part.type}","text":${escape(part.text)}}"""
}
val sb = StringBuilder()
sb.append("""{"parts":[$parts]""")
val pid = prompt.providerID
val mid = prompt.modelID
if (pid != null && mid != null) {
sb.append(""","model":{"providerID":${escape(pid)},"modelID":${escape(mid)}}""")
}
val ag = prompt.agent
if (ag != null) {
sb.append(""","agent":${escape(ag)}""")
}
sb.append("}")
return sb.toString()
}
/**
* Build the partial JSON body for `PATCH /global/config`.
*/
fun buildConfigPartial(update: ConfigUpdateDto): String {
val sb = StringBuilder("{")
var first = true
fun sep() { if (!first) sb.append(","); first = false }
val model = update.model
if (model != null) {
sep(); sb.append(""""model":${escape(model)}""")
}
val agent = update.agent
if (agent != null) {
sep(); sb.append(""""default_agent":${escape(agent)}""")
}
val temp = update.temperature
if (temp != null) {
val target = agent ?: "ask"
sep(); sb.append(""""agent":{"$target":{"temperature":$temp}}""")
}
sb.append("}")
return sb.toString()
}
// ================================================================
// Internal — message/part/session parsing
// ================================================================
internal fun parseMessage(obj: JsonObject): MessageDto {
val time = obj["time"]?.jsonObject
val tokens = obj["tokens"]?.jsonObject
val error = obj["error"]?.jsonObject
return MessageDto(
id = obj.str("id") ?: "",
sessionID = obj.str("sessionID") ?: "",
role = obj.str("role") ?: "unknown",
time = MessageTimeDto(
created = time?.num("created") ?: 0.0,
completed = time?.num("completed"),
),
agent = obj.str("agent"),
providerID = obj.str("providerID"),
modelID = obj.str("modelID"),
parentID = obj.str("parentID"),
cost = obj.num("cost"),
tokens = tokens?.let {
val cache = it["cache"]?.jsonObject
TokensDto(
input = it.long("input") ?: 0,
output = it.long("output") ?: 0,
reasoning = it.long("reasoning") ?: 0,
cacheRead = cache?.long("read") ?: 0,
cacheWrite = cache?.long("write") ?: 0,
)
},
error = error?.let { parseError(it) },
)
}
internal fun parsePart(obj: JsonObject): PartDto {
val state = obj["state"]?.jsonObject
return PartDto(
id = obj.str("id") ?: "",
sessionID = obj.str("sessionID") ?: "",
messageID = obj.str("messageID") ?: "",
type = obj.str("type") ?: "unknown",
text = obj.str("text"),
tool = obj.str("tool"),
state = state?.str("status"),
title = state?.str("title"),
)
}
internal fun parseError(obj: JsonObject): MessageErrorDto {
val type = obj.str("type") ?: "unknown"
val msg = obj.str("message") ?: obj.str("error")
return MessageErrorDto(type, msg)
}
private fun parseSessionObject(obj: JsonObject): SessionDto {
val time = obj["time"]?.jsonObject
val summary = obj["summary"]?.jsonObject
return SessionDto(
id = obj.str("id") ?: "",
projectID = obj.str("projectID") ?: "",
directory = obj.str("directory") ?: "",
parentID = obj.str("parentID"),
title = obj.str("title") ?: "",
version = obj.str("version") ?: "",
time = SessionTimeDto(
created = time?.num("created") ?: 0.0,
updated = time?.num("updated") ?: 0.0,
archived = time?.num("archived"),
),
summary = summary?.let {
SessionSummaryDto(
additions = it.long("additions")?.toInt() ?: 0,
deletions = it.long("deletions")?.toInt() ?: 0,
files = it.long("files")?.toInt() ?: 0,
)
},
)
}
// ================================================================
// Internal — regex-based field extraction (for status events)
// ================================================================
private fun extractField(raw: String, field: String): String? {
val re = FIELD_RE.getOrPut(field) {
Regex(""""$field"\s*:\s*"([^"]+)"""")
}
return re.find(raw)?.groupValues?.get(1)
}
private fun extractNested(raw: String, outer: String, inner: String): String? {
val block = Regex(""""$outer"\s*:\s*\{([^}]+)}""")
.find(raw)?.groupValues?.get(1) ?: return null
return extractField("{$block}", inner)
}
// ================================================================
// Internal — JSON helpers
// ================================================================
private fun tryParseObject(raw: String): JsonObject? =
try { json.parseToJsonElement(raw).jsonObject } catch (_: Exception) { null }
private fun tryParseArray(raw: String): kotlinx.serialization.json.JsonArray? =
try { json.parseToJsonElement(raw).jsonArray } catch (_: Exception) { null }
/** Escape and double-quote a string for manual JSON building. */
private fun escape(value: String): String {
val escaped = value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
return "\"$escaped\""
}
}
// JsonObject convenience extensions
private fun JsonObject.str(key: String): String? =
this[key]?.jsonPrimitive?.contentOrNull
private fun JsonObject.num(key: String): Double? =
this[key]?.jsonPrimitive?.doubleOrNull
private fun JsonObject.long(key: String): Long? =
this[key]?.jsonPrimitive?.longOrNull
@@ -3,14 +3,21 @@
package ai.kilocode.backend.rpc
import ai.kilocode.backend.app.KiloBackendAppService
import ai.kilocode.backend.app.KiloBackendChatManager
import ai.kilocode.backend.app.KiloBackendSessionManager
import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager
import ai.kilocode.rpc.KiloSessionRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionListDto
import ai.kilocode.rpc.dto.SessionStatusDto
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
/**
* Backend implementation of [KiloSessionRpcApi].
@@ -18,9 +25,13 @@ import kotlinx.coroutines.flow.Flow
* Session CRUD routes through the [KiloBackendWorkspaceManager] to
* get the correct workspace for a directory. Status tracking and
* worktree directory management go directly to the
* [KiloBackendSessionManager].
* [KiloBackendSessionManager]. Chat operations delegate to
* [KiloBackendChatManager].
*/
class KiloSessionRpcApiImpl : KiloSessionRpcApi {
companion object {
private val LOG = Logger.getInstance(KiloSessionRpcApiImpl::class.java)
}
private val workspaces: KiloBackendWorkspaceManager
get() = service<KiloBackendAppService>().workspaces
@@ -28,11 +39,16 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
private val sessions: KiloBackendSessionManager
get() = service<KiloBackendAppService>().sessions
private val chat: KiloBackendChatManager
get() = service<KiloBackendAppService>().chat
override suspend fun list(directory: String): SessionListDto =
workspaces.get(directory).sessions()
override suspend fun create(directory: String): SessionDto =
workspaces.get(directory).createSession()
override suspend fun create(directory: String): SessionDto {
LOG.info("create session: directory=$directory")
return workspaces.get(directory).createSession()
}
override suspend fun get(id: String, directory: String): SessionDto {
val dir = sessions.getDirectory(id, directory)
@@ -52,4 +68,34 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
override suspend fun getDirectory(id: String, fallback: String): String =
sessions.getDirectory(id, fallback)
// ------ chat ------
override suspend fun prompt(id: String, directory: String, prompt: PromptDto) {
LOG.info("prompt RPC: session=$id, dir=$directory, parts=${prompt.parts.size}")
chat.prompt(id, directory, prompt)
}
override suspend fun abort(id: String, directory: String) =
chat.abort(id, directory)
override suspend fun messages(id: String, directory: String): List<MessageWithPartsDto> =
chat.messages(id, directory)
override suspend fun events(id: String, directory: String): Flow<ChatEventDto> =
chat.events.filter { event ->
val sid = when (event) {
is ChatEventDto.MessageUpdated -> event.sessionID
is ChatEventDto.PartUpdated -> event.sessionID
is ChatEventDto.PartDelta -> event.sessionID
is ChatEventDto.TurnOpen -> event.sessionID
is ChatEventDto.TurnClose -> event.sessionID
is ChatEventDto.Error -> event.sessionID
is ChatEventDto.MessageRemoved -> event.sessionID
}
sid == id
}
override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) =
chat.updateConfig(directory, config)
}
@@ -1,6 +1,6 @@
package ai.kilocode.backend
import ai.kilocode.backend.util.KiloBackendHttpClients
import ai.kilocode.backend.cli.KiloBackendHttpClients
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import java.util.Base64
@@ -303,7 +303,7 @@ class KiloBackendSessionManagerTest {
assertFailsWith<IllegalStateException> { app.sessions.list("/test") }
// Re-start (simulate what app service does on reconnect)
app.sessions.start(app.api!!, app.events)
app.sessions.start(app.api!!, app.http!!, app.port, app.events)
// CRUD should work again
val result = app.sessions.list("/test")
@@ -0,0 +1,475 @@
package ai.kilocode.backend
import ai.kilocode.backend.cli.KiloCliDataParser
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.PromptPartDto
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Pure unit tests for [KiloCliDataParser].
*
* No mocks, no services, no coroutines — just JSON in → DTO out.
* When a new parsing bug is found, copy the raw JSON that caused
* the issue and add a test case here.
*/
class KiloCliDataParserTest {
// ================================================================
// extractEventType
// ================================================================
@Test
fun `extractEventType - parses type from JSON data`() {
val result = KiloCliDataParser.extractEventType(
"""{"type":"global.config.updated","payload":{}}"""
)
assertEquals("global.config.updated", result)
}
@Test
fun `extractEventType - returns unknown for missing type`() {
assertEquals("unknown", KiloCliDataParser.extractEventType("""{"data":"something"}"""))
}
@Test
fun `extractEventType - returns unknown for empty string`() {
assertEquals("unknown", KiloCliDataParser.extractEventType(""))
}
// ================================================================
// parseChatEvent — GlobalEvent wrapper
// ================================================================
@Test
fun `parseChatEvent - message updated with GlobalEvent wrapper`() {
val data = """{
"directory": "/tmp/test",
"payload": {
"type": "message.updated",
"properties": {
"sessionID": "ses_123",
"info": {
"id": "msg_1",
"sessionID": "ses_123",
"role": "assistant",
"time": { "created": 1000.0 }
}
}
}
}"""
val result = KiloCliDataParser.parseChatEvent("message.updated", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.MessageUpdated)
assertEquals("ses_123", result.sessionID)
assertEquals("msg_1", result.info.id)
assertEquals("assistant", result.info.role)
}
@Test
fun `parseChatEvent - flat event without payload wrapper`() {
val data = """{
"type": "message.updated",
"properties": {
"sessionID": "ses_456",
"info": {
"id": "msg_2",
"sessionID": "ses_456",
"role": "user",
"time": { "created": 2000.0 }
}
}
}"""
val result = KiloCliDataParser.parseChatEvent("message.updated", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.MessageUpdated)
assertEquals("ses_456", result.sessionID)
assertEquals("user", result.info.role)
}
// ================================================================
// parseChatEvent — specific event types
// ================================================================
@Test
fun `parseChatEvent - message part delta`() {
val data = globalEvent("""
"type": "message.part.delta",
"properties": {
"sessionID": "ses_1",
"messageID": "msg_1",
"partID": "part_1",
"field": "text",
"delta": "Hello world"
}
""")
val result = KiloCliDataParser.parseChatEvent("message.part.delta", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.PartDelta)
assertEquals("ses_1", result.sessionID)
assertEquals("msg_1", result.messageID)
assertEquals("part_1", result.partID)
assertEquals("text", result.field)
assertEquals("Hello world", result.delta)
}
@Test
fun `parseChatEvent - message part updated`() {
val data = globalEvent("""
"type": "message.part.updated",
"properties": {
"sessionID": "ses_1",
"part": {
"id": "part_1",
"sessionID": "ses_1",
"messageID": "msg_1",
"type": "text",
"text": "Hello"
}
}
""")
val result = KiloCliDataParser.parseChatEvent("message.part.updated", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.PartUpdated)
assertEquals("ses_1", result.sessionID)
assertEquals("part_1", result.part.id)
assertEquals("text", result.part.type)
assertEquals("Hello", result.part.text)
}
@Test
fun `parseChatEvent - turn open`() {
val data = globalEvent("""
"type": "session.turn.open",
"properties": { "sessionID": "ses_1" }
""")
val result = KiloCliDataParser.parseChatEvent("session.turn.open", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.TurnOpen)
assertEquals("ses_1", result.sessionID)
}
@Test
fun `parseChatEvent - turn close`() {
val data = globalEvent("""
"type": "session.turn.close",
"properties": { "sessionID": "ses_1", "reason": "completed" }
""")
val result = KiloCliDataParser.parseChatEvent("session.turn.close", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.TurnClose)
assertEquals("ses_1", result.sessionID)
assertEquals("completed", result.reason)
}
@Test
fun `parseChatEvent - session error`() {
val data = globalEvent("""
"type": "session.error",
"properties": {
"sessionID": "ses_1",
"error": { "type": "provider_auth", "message": "Invalid key" }
}
""")
val result = KiloCliDataParser.parseChatEvent("session.error", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.Error)
assertEquals("ses_1", result.sessionID)
assertEquals("provider_auth", result.error?.type)
assertEquals("Invalid key", result.error?.message)
}
@Test
fun `parseChatEvent - message removed`() {
val data = globalEvent("""
"type": "message.removed",
"properties": { "sessionID": "ses_1", "messageID": "msg_1" }
""")
val result = KiloCliDataParser.parseChatEvent("message.removed", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.MessageRemoved)
assertEquals("ses_1", result.sessionID)
assertEquals("msg_1", result.messageID)
}
// ================================================================
// parseChatEvent — error cases
// ================================================================
@Test
fun `parseChatEvent - unknown type returns null`() {
val data = globalEvent("""
"type": "some.unknown.event",
"properties": { "sessionID": "ses_1" }
""")
assertNull(KiloCliDataParser.parseChatEvent("some.unknown.event", data))
}
@Test
fun `parseChatEvent - malformed JSON returns null`() {
assertNull(KiloCliDataParser.parseChatEvent("message.updated", "not json"))
}
@Test
fun `parseChatEvent - missing properties returns null`() {
assertNull(KiloCliDataParser.parseChatEvent("message.updated", """{"payload":{"type":"x"}}"""))
}
@Test
fun `parseChatEvent - missing sessionID returns null`() {
val data = globalEvent("""
"type": "message.updated",
"properties": { "info": { "id": "msg_1", "role": "user", "time": {} } }
""")
assertNull(KiloCliDataParser.parseChatEvent("message.updated", data))
}
// ================================================================
// parseSessionStatus
// ================================================================
@Test
fun `parseSessionStatus - valid status event`() {
val data = """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}"""
val result = KiloCliDataParser.parseSessionStatus(data)
assertNotNull(result)
assertEquals("ses_abc", result.first)
assertEquals("busy", result.second.type)
assertEquals("Running...", result.second.message)
}
@Test
fun `parseSessionStatus - missing sessionID returns null`() {
val data = """{"status":{"type":"idle"}}"""
assertNull(KiloCliDataParser.parseSessionStatus(data))
}
@Test
fun `parseSessionStatus - missing status defaults to idle`() {
val data = """{"sessionID":"ses_xyz"}"""
val result = KiloCliDataParser.parseSessionStatus(data)
assertNotNull(result)
assertEquals("idle", result.second.type)
assertNull(result.second.message)
}
// ================================================================
// parseSession
// ================================================================
@Test
fun `parseSession - full session response`() {
val raw = """{
"id": "ses_abc",
"projectID": "proj_1",
"directory": "/tmp/project",
"parentID": null,
"title": "Test session",
"version": "1",
"time": { "created": 1000.0, "updated": 2000.0 },
"summary": { "additions": 10, "deletions": 5, "files": 3 }
}"""
val result = KiloCliDataParser.parseSession(raw)
assertEquals("ses_abc", result.id)
assertEquals("proj_1", result.projectID)
assertEquals("/tmp/project", result.directory)
assertNull(result.parentID)
assertEquals("Test session", result.title)
assertEquals(1000.0, result.time.created)
assertEquals(2000.0, result.time.updated)
assertNotNull(result.summary)
assertEquals(10, result.summary?.additions)
assertEquals(5, result.summary?.deletions)
assertEquals(3, result.summary?.files)
}
@Test
fun `parseSession - minimal session response`() {
val raw = """{
"id": "ses_min",
"projectID": "proj_2",
"directory": "/tmp",
"title": "",
"version": "0",
"time": { "created": 0.0, "updated": 0.0 }
}"""
val result = KiloCliDataParser.parseSession(raw)
assertEquals("ses_min", result.id)
assertNull(result.summary)
}
// ================================================================
// parseMessages
// ================================================================
@Test
fun `parseMessages - empty array`() {
assertEquals(emptyList(), KiloCliDataParser.parseMessages("[]"))
}
@Test
fun `parseMessages - user and assistant messages`() {
val raw = """[
{
"info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } },
"parts": [{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "Hello" }]
},
{
"info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 } },
"parts": [{ "id": "p2", "sessionID": "s1", "messageID": "m2", "type": "text", "text": "Hi there" }]
}
]"""
val result = KiloCliDataParser.parseMessages(raw)
assertEquals(2, result.size)
assertEquals("user", result[0].info.role)
assertEquals("Hello", result[0].parts[0].text)
assertEquals("assistant", result[1].info.role)
assertEquals("Hi there", result[1].parts[0].text)
}
@Test
fun `parseMessages - message with tool parts`() {
val raw = """[{
"info": { "id": "m1", "sessionID": "s1", "role": "assistant", "time": { "created": 1.0 } },
"parts": [{
"id": "p1",
"sessionID": "s1",
"messageID": "m1",
"type": "tool",
"tool": "read_file",
"state": { "status": "completed", "title": "Read file.txt" }
}]
}]"""
val result = KiloCliDataParser.parseMessages(raw)
assertEquals(1, result.size)
val part = result[0].parts[0]
assertEquals("tool", part.type)
assertEquals("read_file", part.tool)
assertEquals("completed", part.state)
assertEquals("Read file.txt", part.title)
}
@Test
fun `parseMessages - malformed JSON returns empty`() {
assertEquals(emptyList(), KiloCliDataParser.parseMessages("not json"))
}
@Test
fun `parseMessages - message with tokens`() {
val raw = """[{
"info": {
"id": "m1", "sessionID": "s1", "role": "assistant",
"time": { "created": 1.0, "completed": 2.0 },
"tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 20, "write": 5 } },
"cost": 0.005
},
"parts": []
}]"""
val result = KiloCliDataParser.parseMessages(raw)
val info = result[0].info
assertNotNull(info.tokens)
assertEquals(100L, info.tokens?.input)
assertEquals(50L, info.tokens?.output)
assertEquals(10L, info.tokens?.reasoning)
assertEquals(20L, info.tokens?.cacheRead)
assertEquals(5L, info.tokens?.cacheWrite)
assertEquals(0.005, info.cost)
assertEquals(2.0, info.time.completed)
}
// ================================================================
// buildPromptJson
// ================================================================
@Test
fun `buildPromptJson - text only`() {
val prompt = PromptDto(parts = listOf(PromptPartDto("text", "Hello")))
val result = KiloCliDataParser.buildPromptJson(prompt)
assertEquals("""{"parts":[{"type":"text","text":"Hello"}]}""", result)
}
@Test
fun `buildPromptJson - with model override`() {
val prompt = PromptDto(
parts = listOf(PromptPartDto("text", "Hi")),
providerID = "anthropic",
modelID = "claude-4",
)
val result = KiloCliDataParser.buildPromptJson(prompt)
assertTrue(result.contains(""""model":{"providerID":"anthropic","modelID":"claude-4"}"""))
}
@Test
fun `buildPromptJson - with agent`() {
val prompt = PromptDto(
parts = listOf(PromptPartDto("text", "Hi")),
agent = "ask",
)
val result = KiloCliDataParser.buildPromptJson(prompt)
assertTrue(result.contains(""""agent":"ask""""))
}
@Test
fun `buildPromptJson - escapes special characters`() {
val prompt = PromptDto(parts = listOf(PromptPartDto("text", "line1\nline2\t\"quoted\"")))
val result = KiloCliDataParser.buildPromptJson(prompt)
assertTrue(result.contains("""line1\nline2\t\"quoted\""""))
}
// ================================================================
// buildConfigPartial
// ================================================================
@Test
fun `buildConfigPartial - model only`() {
val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(model = "anthropic/claude-4"))
assertEquals("""{"model":"anthropic/claude-4"}""", result)
}
@Test
fun `buildConfigPartial - agent and temperature`() {
val result = KiloCliDataParser.buildConfigPartial(
ConfigUpdateDto(agent = "code", temperature = 0.7)
)
assertTrue(result.contains(""""default_agent":"code""""))
assertTrue(result.contains(""""agent":{"code":{"temperature":0.7}}"""))
}
@Test
fun `buildConfigPartial - empty update`() {
val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto())
assertEquals("{}", result)
}
@Test
fun `buildConfigPartial - temperature without agent defaults to ask`() {
val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(temperature = 0.5))
assertTrue(result.contains(""""agent":{"ask":{"temperature":0.5}}"""))
}
// ================================================================
// Helpers
// ================================================================
/** Wrap payload content in a GlobalEvent structure. */
private fun globalEvent(payload: String): String =
"""{"directory":"/tmp","payload":{$payload}}"""
}
@@ -1,6 +1,6 @@
package ai.kilocode.backend
import ai.kilocode.backend.app.CliServer
import ai.kilocode.backend.cli.CliServer
import ai.kilocode.backend.app.ConnectionState
import ai.kilocode.backend.app.KiloConnectionService
import ai.kilocode.backend.testing.FakeCliServer
@@ -147,18 +147,7 @@ class KiloConnectionServiceTest {
assertTrue(fake.forceExtract)
}
@Test
fun `extractType parses type from JSON data`() {
val svc = KiloConnectionService(scope, fake, {}, log)
val result = svc.extractType("""{"type":"global.config.updated","payload":{}}""")
assertEquals("global.config.updated", result)
}
@Test
fun `extractType returns unknown for missing type`() {
val svc = KiloConnectionService(scope, fake, {}, log)
assertEquals("unknown", svc.extractType("""{"data":"something"}"""))
}
// extractType tests moved to KiloCliDataParserTest
@Test
fun `dispose transitions to Disconnected`() = runBlocking {
@@ -1,6 +1,6 @@
package ai.kilocode.backend.testing
import ai.kilocode.backend.app.CliServer
import ai.kilocode.backend.cli.CliServer
/**
* Fake [CliServer] that delegates to a [MockCliServer] instead of
@@ -3,6 +3,11 @@
package ai.kilocode.client
import ai.kilocode.rpc.KiloSessionRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.PromptPartDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionStatusDto
import com.intellij.openapi.components.Service
@@ -10,21 +15,23 @@ import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.project.Project
import fleet.rpc.client.durable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/**
* Project-level frontend service for session management.
* Project-level frontend service for session management and chat.
*
* Provides session CRUD, active session tracking, and live
* status updates via [KiloSessionRpcApi]. All operations are
* scoped to the project's [directory] by default, with support
* for per-session worktree directory overrides.
* Provides session CRUD, active session tracking, live status
* updates, and chat operations via [KiloSessionRpcApi]. All
* operations are scoped to the project's [directory] by default,
* with support for per-session worktree directory overrides.
*/
@Service(Service.Level.PROJECT)
class KiloSessionService(
@@ -35,7 +42,14 @@ class KiloSessionService(
private val LOG = Logger.getInstance(KiloSessionService::class.java)
}
private val directory: String get() = project.basePath ?: ""
private val directory: String
get() {
val path = project.basePath ?: ""
if (path.isEmpty()) {
LOG.warn("project.basePath is null/empty — session operations will likely fail")
}
return path
}
private val _sessions = MutableStateFlow<List<SessionDto>>(emptyList())
val sessions: StateFlow<List<SessionDto>> = _sessions.asStateFlow()
@@ -112,4 +126,100 @@ class KiloSessionService(
}
}
}
// ------ chat ------
/**
* Send a text prompt to the active session. Creates a session if needed.
*
* @param text The user's message text
* @param providerID Optional model override (provider part)
* @param modelID Optional model override (model part)
* @param agent Optional agent/mode override (e.g. "ask", "code")
*/
fun prompt(text: String, providerID: String? = null, modelID: String? = null, agent: String? = null) {
cs.launch {
try {
LOG.info("prompt: ensuring session exists (active=${_active.value?.id})")
val session = ensureSession()
LOG.info("prompt: session=${session.id}, dir=$directory, text=${text.take(80)}")
val prompt = PromptDto(
parts = listOf(PromptPartDto(type = "text", text = text)),
providerID = providerID,
modelID = modelID,
agent = agent,
)
LOG.info("prompt: calling RPC prompt...")
durable { KiloSessionRpcApi.getInstance().prompt(session.id, directory, prompt) }
LOG.info("prompt: RPC returned successfully")
} catch (e: Exception) {
LOG.warn("prompt failed", e)
}
}
}
/** Abort ongoing processing for the active session. */
fun abort() {
cs.launch {
val session = _active.value ?: return@launch
try {
durable { KiloSessionRpcApi.getInstance().abort(session.id, directory) }
} catch (e: Exception) {
LOG.warn("abort failed", e)
}
}
}
/** Load message history for the active session. */
suspend fun messages(): List<MessageWithPartsDto> {
val session = _active.value ?: return emptyList()
return try {
durable { KiloSessionRpcApi.getInstance().messages(session.id, directory) }
} catch (e: Exception) {
LOG.warn("messages failed", e)
emptyList()
}
}
/**
* Subscribe to streaming chat events for the active session.
* Returns an empty flow if no session is active.
*/
fun events(): Flow<ChatEventDto> {
val session = _active.value ?: return emptyFlow()
return flow {
durable {
KiloSessionRpcApi.getInstance()
.events(session.id, directory)
.collect { emit(it) }
}
}
}
/** Update config (model, agent/mode, temperature). */
fun updateConfig(config: ConfigUpdateDto) {
cs.launch {
try {
durable { KiloSessionRpcApi.getInstance().updateConfig(directory, config) }
} catch (e: Exception) {
LOG.warn("config update failed", e)
}
}
}
// ------ helpers ------
/**
* Ensure an active session exists. Creates one if needed.
*/
private suspend fun ensureSession(): SessionDto {
_active.value?.let { return it }
val dir = directory
LOG.info("ensureSession: creating new session in dir=$dir")
val session = durable { KiloSessionRpcApi.getInstance().create(dir) }
LOG.info("ensureSession: created session ${session.id}")
_active.value = session
refresh()
return session
}
}
@@ -1,5 +1,7 @@
package ai.kilocode.client
import ai.kilocode.client.chat.ChatPanel
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
@@ -9,13 +11,13 @@ import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.ui.content.ContentFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* Creates the Kilo Code tool window content.
*
* Wires [KiloAppService] and [KiloProjectService] into a
* [KiloWelcomeUi] panel that shows app + workspace initialization
* status. All UI logic lives in [KiloWelcomeUi].
* Starts with a [KiloWelcomeUi] status panel. Once the backend reaches
* [KiloAppStatusDto.READY], adds a [ChatPanel] tab and switches to it.
*/
class KiloToolWindowFactory : ToolWindowFactory {
@@ -27,12 +29,32 @@ class KiloToolWindowFactory : ToolWindowFactory {
try {
val app = service<KiloAppService>()
val workspace = project.service<KiloProjectService>()
val sessions = project.service<KiloSessionService>()
val scope = CoroutineScope(SupervisorJob())
val ui = KiloWelcomeUi(app, workspace, scope)
val content = ContentFactory.getInstance().createContent(ui, "", false)
content.setDisposer(ui)
toolWindow.contentManager.addContent(content)
// Welcome/status tab
val welcome = KiloWelcomeUi(app, workspace, scope)
val statusContent = ContentFactory.getInstance()
.createContent(welcome, "Status", false)
statusContent.setDisposer(welcome)
toolWindow.contentManager.addContent(statusContent)
// Chat tab — added once the backend is ready
val chatScope = CoroutineScope(SupervisorJob())
val chat = ChatPanel(sessions, workspace, chatScope)
val chatContent = ContentFactory.getInstance()
.createContent(chat, "Chat", false)
chatContent.setDisposer(chat)
toolWindow.contentManager.addContent(chatContent)
// Switch to chat tab when ready
scope.launch {
app.state.collect { state ->
if (state.status == KiloAppStatusDto.READY) {
toolWindow.contentManager.setSelectedContent(chatContent)
}
}
}
ActionManager.getInstance().getAction("Kilo.Settings")?.let {
toolWindow.setTitleActions(listOf(it))
@@ -0,0 +1,75 @@
package ai.kilocode.client.chat
import com.intellij.icons.AllIcons
import com.intellij.ui.components.JBTextArea
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import javax.swing.JButton
import javax.swing.JPanel
/**
* Chat input area with a text field and send/abort button.
*
* Enter sends the message. Shift+Enter inserts a newline.
* When busy, the button changes to an abort button.
*/
class ChatInputPanel(
private val onSend: (String) -> Unit,
private val onAbort: () -> Unit,
) : JPanel(BorderLayout()) {
private val area = JBTextArea(3, 40).apply {
lineWrap = true
wrapStyleWord = true
border = JBUI.Borders.empty(4)
emptyText.text = "Type a message..."
}
private val button = JButton("Send").apply {
addActionListener { handleClick() }
}
@Volatile
private var busy = false
init {
border = JBUI.Borders.empty(4, 8)
area.addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
if (e.keyCode == KeyEvent.VK_ENTER && !e.isShiftDown) {
e.consume()
if (!busy) {
onSend(area.text.trim())
}
}
}
})
add(area, BorderLayout.CENTER)
add(button, BorderLayout.EAST)
}
fun setBusy(value: Boolean) {
busy = value
button.text = if (value) "Stop" else "Send"
button.icon = if (value) AllIcons.Actions.Suspend else null
}
fun clearInput() {
area.text = ""
}
private fun handleClick() {
if (busy) {
onAbort()
} else {
val text = area.text.trim()
if (text.isNotEmpty()) {
onSend(text)
}
}
}
}
@@ -0,0 +1,191 @@
package ai.kilocode.client.chat
import ai.kilocode.client.KiloProjectService
import ai.kilocode.client.KiloSessionService
import ai.kilocode.rpc.dto.AgentsDto
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelDto
import ai.kilocode.rpc.dto.ProviderDto
import ai.kilocode.rpc.dto.ProvidersDto
import ai.kilocode.rpc.dto.SessionStatusDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.JBUI
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.awt.BorderLayout
import javax.swing.JPanel
/**
* Main chat panel composing the toolbar, message list, and input area.
*
* Wires [KiloSessionService] for chat operations and [KiloProjectService]
* for provider/agent data. Subscribes to SSE chat events for streaming.
*/
class ChatPanel(
private val sessions: KiloSessionService,
private val workspace: KiloProjectService,
private val cs: CoroutineScope,
) : JPanel(BorderLayout()), Disposable {
private val messages = MessageListPanel()
private val scroll = JBScrollPane(messages).apply {
border = JBUI.Borders.empty()
verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED
horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER
}
private val toolbar = ChatToolbar(
onModeChanged = { agent -> sessions.updateConfig(ConfigUpdateDto(agent = agent)) },
onModelChanged = { provider, model -> sessions.updateConfig(ConfigUpdateDto(model = "$provider/$model")) },
)
private val input = ChatInputPanel(
onSend = { text -> send(text) },
onAbort = { sessions.abort() },
)
private var eventJob: Job? = null
private var statusJob: Job? = null
private var wsJob: Job? = null
init {
add(toolbar, BorderLayout.NORTH)
add(scroll, BorderLayout.CENTER)
add(input, BorderLayout.SOUTH)
// Watch workspace state for providers/agents
wsJob = cs.launch {
workspace.state.collect { state ->
if (state.status == KiloWorkspaceStatusDto.READY) {
edt {
state.providers?.let { toolbar.setProviders(it) }
state.agents?.let { toolbar.setAgents(it) }
}
}
}
}
// Watch session statuses for busy/idle state
statusJob = cs.launch {
sessions.statuses.collect { statuses ->
val active = sessions.active.value?.id ?: return@collect
val status = statuses[active]
edt { input.setBusy(status?.type == "busy") }
}
}
// Watch active session changes
cs.launch {
sessions.active.collect { session ->
edt {
messages.clear()
input.setBusy(false)
}
eventJob?.cancel()
if (session != null) {
loadHistory(session.id)
subscribeEvents()
}
}
}
}
private fun send(text: String) {
if (text.isBlank()) return
sessions.prompt(text)
input.clearInput()
}
private fun loadHistory(id: String) {
cs.launch {
val history = sessions.messages()
edt {
messages.clear()
for (msg in history) {
messages.addMessage(msg.info)
for (part in msg.parts) {
val txt = part.text
if (part.type == "text" && txt != null) {
messages.updatePartText(msg.info.id, part.id, txt)
}
}
}
scrollToBottom()
}
}
}
private fun subscribeEvents() {
eventJob = cs.launch {
sessions.events().collect { event ->
edt { handleEvent(event) }
}
}
}
private fun handleEvent(event: ChatEventDto) {
when (event) {
is ChatEventDto.MessageUpdated -> {
messages.addMessage(event.info)
scrollToBottom()
}
is ChatEventDto.PartUpdated -> {
val txt = event.part.text
if (event.part.type == "text" && txt != null) {
messages.updatePartText(event.part.messageID, event.part.id, txt)
scrollToBottom()
}
}
is ChatEventDto.PartDelta -> {
if (event.field == "text") {
messages.appendDelta(event.messageID, event.partID, event.delta)
scrollToBottom()
}
}
is ChatEventDto.TurnOpen -> {
input.setBusy(true)
}
is ChatEventDto.TurnClose -> {
input.setBusy(false)
}
is ChatEventDto.Error -> {
val msg = event.error?.message ?: event.error?.type ?: "Unknown error"
messages.addError(msg)
input.setBusy(false)
scrollToBottom()
}
is ChatEventDto.MessageRemoved -> {
messages.removeMessage(event.messageID)
}
}
}
private fun scrollToBottom() {
val bar = scroll.verticalScrollBar
bar.value = bar.maximum
}
private fun edt(block: () -> Unit) {
ApplicationManager.getApplication().invokeLater(block)
}
override fun dispose() {
eventJob?.cancel()
statusJob?.cancel()
wsJob?.cancel()
cs.cancel()
}
}
@@ -0,0 +1,106 @@
package ai.kilocode.client.chat
import ai.kilocode.rpc.dto.AgentDto
import ai.kilocode.rpc.dto.AgentsDto
import ai.kilocode.rpc.dto.ModelDto
import ai.kilocode.rpc.dto.ProviderDto
import ai.kilocode.rpc.dto.ProvidersDto
import com.intellij.openapi.ui.ComboBox
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import java.awt.FlowLayout
import javax.swing.DefaultComboBoxModel
import javax.swing.JPanel
/**
* Toolbar with mode (agent) and model selection dropdowns.
*
* Populated from workspace data (providers, agents). Changes
* are forwarded via callbacks to the session service for
* config updates.
*/
class ChatToolbar(
private val onModeChanged: (String) -> Unit,
private val onModelChanged: (String, String) -> Unit,
) : JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), JBUI.scale(2))) {
private val modeLabel = JBLabel("Mode:")
private val modeCombo = ComboBox<AgentItem>().apply {
addActionListener {
val item = selectedItem as? AgentItem ?: return@addActionListener
if (!updating) onModeChanged(item.name)
}
}
private val modelLabel = JBLabel("Model:")
private val modelCombo = ComboBox<ModelItem>().apply {
addActionListener {
val item = selectedItem as? ModelItem ?: return@addActionListener
if (!updating) onModelChanged(item.provider, item.id)
}
}
@Volatile
private var updating = false
init {
border = JBUI.Borders.empty(2, 8)
add(modeLabel)
add(modeCombo)
add(modelLabel)
add(modelCombo)
}
fun setAgents(agents: AgentsDto) {
updating = true
try {
val model = DefaultComboBoxModel<AgentItem>()
for (agent in agents.agents) {
model.addElement(AgentItem(agent.name, agent.displayName ?: agent.name))
}
modeCombo.model = model
// Select the default agent
val idx = agents.agents.indexOfFirst { it.name == agents.default }
if (idx >= 0) modeCombo.selectedIndex = idx
} finally {
updating = false
}
}
fun setProviders(providers: ProvidersDto) {
updating = true
try {
val model = DefaultComboBoxModel<ModelItem>()
for (provider in providers.providers) {
if (provider.id !in providers.connected) continue
for ((id, info) in provider.models) {
model.addElement(ModelItem(provider.id, id, "${provider.name} / ${info.name}"))
}
}
modelCombo.model = model
// Select the default model
val defaults = providers.defaults
if (defaults.isNotEmpty()) {
val entry = defaults.entries.firstOrNull()
if (entry != null) {
val idx = (0 until model.size).firstOrNull { i ->
val item = model.getElementAt(i)
item.provider == entry.key && item.id == entry.value
}
if (idx != null) modelCombo.selectedIndex = idx
}
}
} finally {
updating = false
}
}
}
private data class AgentItem(val name: String, val display: String) {
override fun toString() = display
}
private data class ModelItem(val provider: String, val id: String, val display: String) {
override fun toString() = display
}
@@ -0,0 +1,137 @@
package ai.kilocode.client.chat
import ai.kilocode.rpc.dto.MessageDto
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.Component
import javax.swing.BoxLayout
import javax.swing.JPanel
import javax.swing.JTextArea
import javax.swing.SwingConstants
/**
* Scrollable panel displaying chat messages.
*
* Each message is rendered as a role label + text area block.
* Supports incremental text updates via part IDs for streaming.
*/
class MessageListPanel : JPanel() {
/** Maps messageID to the panel for that message. */
private val panels = LinkedHashMap<String, MessageBlock>()
init {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = true
background = UIUtil.getPanelBackground()
border = JBUI.Borders.empty(8)
}
fun addMessage(info: MessageDto) {
if (panels.containsKey(info.id)) return
val block = MessageBlock(info)
panels[info.id] = block
add(block)
revalidate()
repaint()
}
fun updatePartText(messageID: String, partID: String, text: String) {
val block = panels[messageID] ?: return
block.setText(partID, text)
}
fun appendDelta(messageID: String, partID: String, delta: String) {
val block = panels[messageID] ?: return
block.appendDelta(partID, delta)
}
fun removeMessage(messageID: String) {
val block = panels.remove(messageID) ?: return
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, 8)
alignmentX = Component.LEFT_ALIGNMENT
}
add(label)
revalidate()
repaint()
}
fun clear() {
panels.clear()
removeAll()
revalidate()
repaint()
}
}
/**
* A single message block: role header + text content area.
*/
private class MessageBlock(info: MessageDto) : JPanel(BorderLayout()) {
private val parts = LinkedHashMap<String, JTextArea>()
private val body = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
}
init {
isOpaque = false
border = JBUI.Borders.empty(6, 0)
alignmentX = Component.LEFT_ALIGNMENT
val role = when (info.role) {
"user" -> "You"
"assistant" -> "Assistant"
else -> info.role
}
val header = JBLabel(role).apply {
font = JBUI.Fonts.label().deriveFont(JBUI.Fonts.label().style or java.awt.Font.BOLD)
foreground = when (info.role) {
"user" -> UIUtil.getLabelForeground()
else -> JBColor(0x4A90D9, 0x6CB4EE)
}
border = JBUI.Borders.empty(0, 0, 4, 0)
horizontalAlignment = SwingConstants.LEFT
}
add(header, BorderLayout.NORTH)
add(body, BorderLayout.CENTER)
}
fun setText(partID: String, text: String) {
val area = parts.getOrPut(partID) { createArea().also { body.add(it) } }
area.text = text
body.revalidate()
}
fun appendDelta(partID: String, delta: String) {
val area = parts.getOrPut(partID) { createArea().also { body.add(it) } }
area.append(delta)
body.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 = Component.LEFT_ALIGNMENT
}
}
@@ -1,5 +1,9 @@
package ai.kilocode.rpc
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionListDto
import ai.kilocode.rpc.dto.SessionStatusDto
@@ -45,4 +49,21 @@ interface KiloSessionRpcApi : RemoteApi<Unit> {
/** Get the effective directory for a session (worktree or fallback). */
suspend fun getDirectory(id: String, fallback: String): String
// ------ chat ------
/** Send a prompt to a session (fire-and-forget). */
suspend fun prompt(id: String, directory: String, prompt: PromptDto)
/** Abort ongoing processing for a session. */
suspend fun abort(id: String, directory: String)
/** Load message history for a session. */
suspend fun messages(id: String, directory: String): List<MessageWithPartsDto>
/** Subscribe to streaming chat events for a specific session. */
suspend fun events(id: String, directory: String): Flow<ChatEventDto>
/** Update config (model, agent/mode, temperature). */
suspend fun updateConfig(directory: String, config: ConfigUpdateDto)
}
@@ -0,0 +1,144 @@
package ai.kilocode.rpc.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// --- Messages ---
@Serializable
data class MessageDto(
val id: String,
val sessionID: String,
val role: String,
val time: MessageTimeDto,
val agent: String? = null,
val providerID: String? = null,
val modelID: String? = null,
val parentID: String? = null,
val cost: Double? = null,
val tokens: TokensDto? = null,
val error: MessageErrorDto? = null,
)
@Serializable
data class MessageTimeDto(
val created: Double,
val completed: Double? = null,
)
@Serializable
data class TokensDto(
val input: Long,
val output: Long,
val reasoning: Long,
val cacheRead: Long,
val cacheWrite: Long,
)
@Serializable
data class MessageErrorDto(
val type: String,
val message: String? = null,
)
@Serializable
data class MessageWithPartsDto(
val info: MessageDto,
val parts: List<PartDto>,
)
// --- Parts (simplified for basic chat) ---
@Serializable
data class PartDto(
val id: String,
val sessionID: String,
val messageID: String,
val type: String,
val text: String? = null,
val tool: String? = null,
val state: String? = null,
val title: String? = null,
)
// --- Prompt Input ---
@Serializable
data class PromptDto(
val parts: List<PromptPartDto>,
val providerID: String? = null,
val modelID: String? = null,
val agent: String? = null,
)
@Serializable
data class PromptPartDto(
val type: String,
val text: String,
)
// --- Streaming Events ---
@Serializable
sealed class ChatEventDto {
@Serializable
@SerialName("message.updated")
data class MessageUpdated(
val sessionID: String,
val info: MessageDto,
) : ChatEventDto()
@Serializable
@SerialName("part.updated")
data class PartUpdated(
val sessionID: String,
val part: PartDto,
) : ChatEventDto()
@Serializable
@SerialName("part.delta")
data class PartDelta(
val sessionID: String,
val messageID: String,
val partID: String,
val field: String,
val delta: String,
) : ChatEventDto()
@Serializable
@SerialName("turn.open")
data class TurnOpen(
val sessionID: String,
) : ChatEventDto()
@Serializable
@SerialName("turn.close")
data class TurnClose(
val sessionID: String,
val reason: String,
) : ChatEventDto()
@Serializable
@SerialName("error")
data class Error(
val sessionID: String?,
val error: MessageErrorDto? = null,
) : ChatEventDto()
@Serializable
@SerialName("message.removed")
data class MessageRemoved(
val sessionID: String,
val messageID: String,
) : ChatEventDto()
}
// --- Config Update ---
@Serializable
data class ConfigUpdateDto(
val model: String? = null,
val agent: String? = null,
val temperature: Double? = null,
)