mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
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:
@@ -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.
|
||||
+21
-3
@@ -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
|
||||
|
||||
+191
@@ -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")
|
||||
}
|
||||
+10
-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()
|
||||
|
||||
+44
-48
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.backend.app
|
||||
package ai.kilocode.backend.cli
|
||||
|
||||
/**
|
||||
* Abstraction over the CLI process lifecycle.
|
||||
+6
-54
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package ai.kilocode.backend.util
|
||||
package ai.kilocode.backend.cli
|
||||
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Interceptor
|
||||
+341
@@ -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
|
||||
+49
-3
@@ -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
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
+475
@@ -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}}"""
|
||||
}
|
||||
+2
-13
@@ -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
-1
@@ -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
|
||||
|
||||
+116
-6
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+29
-7
@@ -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))
|
||||
|
||||
+75
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -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()
|
||||
}
|
||||
}
|
||||
+106
@@ -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
|
||||
}
|
||||
+137
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user