Skip to content

Commit 1da343f

Browse files
authored
Merge pull request #9176 from Kilo-Org/detailed-sweater
feat(jetbrains): complete JetBrains chat stack — backend, model, and permission/question RPC
2 parents 174833b + 6d998ea commit 1da343f

50 files changed

Lines changed: 4661 additions & 1128 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/kilo-jetbrains/AGENTS.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,135 @@
7474
- Service classes ↔ `<applicationService>`/`<projectService>` entries in the corresponding module XML
7575
- `script/build.ts` platform list ↔ `backend/build.gradle.kts` `requiredPlatforms` list
7676

77+
## Session Component
78+
79+
The chat session feature uses a three-layer Model / Controller / View architecture. All files live under
80+
`frontend/src/main/kotlin/ai/kilocode/client/session/`.
81+
82+
### Layers
83+
84+
**`SessionModel`** (`model/SessionModel.kt`)
85+
86+
- Single source of truth for session content and runtime state.
87+
- **EDT-only access** — no synchronisation. `SessionController` guarantees all reads and writes happen on the EDT.
88+
- State is mutated only through dedicated methods (`setState`, `upsertMessage`, `setDiff`, etc.), never via direct field assignment from outside the model.
89+
- Every mutation fires a sealed `SessionModelEvent` that carries the data needed for rendering — UI never needs to read back from the model after receiving an event.
90+
- Each event overrides `toString()` with a compact, stable label (e.g. `"MessageAdded msg1"`, `"DiffUpdated files=2"`). Tests assert events by comparing joined `toString()` output.
91+
- `loadHistory()` and `clear()` reset all state fields — diff, todos, compactionCount, messages, and `SessionState.Idle`. Call them when opening or clearing a session.
92+
93+
**`SessionController`** (`SessionController.kt`)
94+
95+
- Owns one `SessionModel`. UIs read from `model` and subscribe to `SessionModelEvent` via `model.addListener()`.
96+
- Accepts an optional `id` at construction.
97+
- `id = null` → lazily creates a new session on the first `prompt()` call. This guarantees events are subscribed before the prompt is sent, eliminating race conditions.
98+
- `id != null` → immediately loads history and subscribes to SSE events on construction.
99+
- After history load, `recoverPending()` seeds state in this priority order: (1) pending permission, (2) pending question, (3) current session status (`busy`/`retry`/`offline` from `KiloSessionService.statuses`), (4) `Idle`.
100+
- All SSE events are filtered by `sessionID` before being handled. `session.error` events with `null` sessionID are treated as global and pass through.
101+
- Publishes coarser lifecycle updates (app/workspace changes, view switching) via `SessionControllerEvent` to registered listeners — keep these separate from the fine-grained `SessionModelEvent` stream.
102+
103+
**View** (UI classes under `ui/`)
104+
105+
- Listens to `SessionModelEvent` via `model.addListener(parent) { event -> when(event) { ... } }`.
106+
- The `when` block must be exhaustive — add `-> Unit` branches for events the view intentionally ignores so new events surface as compile errors.
107+
- Views call `SessionController` actions (`prompt()`, `replyPermission()`, etc.) on the EDT; the controller dispatches RPC calls to a coroutine scope.
108+
- Views must never access RPC or services directly — everything goes through the controller.
109+
110+
### Adding a New Event
111+
112+
1. Add a subclass to `SessionModelEvent` with a stable `toString()`.
113+
2. Add the corresponding state field and mutation method to `SessionModel`. Reset the field in both `loadHistory()` and `clear()`.
114+
3. Handle the new `ChatEventDto` in `SessionController.handle()` by calling the model mutation method.
115+
4. Add `-> Unit` stubs for the new event in any existing exhaustive `when` blocks in view code.
116+
117+
### Testing
118+
119+
Controller tests extend `SessionControllerTestBase` (`test/…/session/SessionControllerTestBase.kt`),
120+
which provides a real IntelliJ Application and EDT via `BasePlatformTestCase`, real frontend services
121+
wired to `FakeSessionRpcApi`, and a set of shared helpers.
122+
123+
**Two setups:**
124+
125+
| Setup | When to use |
126+
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
127+
| `val (m, events, modelEvents) = prompted()` | New-session flow — sets app/workspace to ready, creates a controller with no ID, sends an initial prompt. `model.showMessages` is `true`. Start all event-driven tests from here. |
128+
| `controller("ses_test")` + manual `appRpc`/`projectRpc` setup + `flush()` | Existing-session flow — opens a specific session, triggers history load and `recoverPending()`. `model.showMessages` is `false`. Use for recovery and history tests. Pass `show = false` to `assertSession`. |
129+
130+
**Core assertion helpers:**
131+
132+
```kotlin
133+
// Full controller state — includes model transcript + status line.
134+
// show=true is the default; pass show=false for existing-session tests.
135+
assertSession("""
136+
assistant#msg1
137+
text#prt1:
138+
hello
139+
140+
[code] [kilo/gpt-5] [idle]
141+
""", m)
142+
143+
// Just the model transcript (no status line)
144+
assertModel("diff: src/A.kt src/B.kt", m)
145+
146+
// Model event stream — one event per line via event.toString()
147+
assertModelEvents("""
148+
MessageAdded msg1
149+
ContentAdded msg1/prt1
150+
""", modelEvents)
151+
152+
// Controller lifecycle events
153+
assertControllerEvents("WorkspaceReady", events)
154+
```
155+
156+
**Emitting events and flushing:**
157+
158+
```kotlin
159+
emit(ChatEventDto.TurnOpen("ses_test")) // emits + flushes by default
160+
emit(ChatEventDto.PartDelta(…), flush = false) // batch without intermediate flush
161+
flush() // settle coroutines + drain EDT
162+
```
163+
164+
**`FakeSessionRpcApi` configurable state:**
165+
166+
| Field | Purpose |
167+
| ------------------------------------------------------------------ | ------------------------------------------------------ |
168+
| `rpc.events` (`MutableSharedFlow`) | Emit `ChatEventDto` events the controller will receive |
169+
| `rpc.statuses` (`MutableStateFlow<Map<String, SessionStatusDto>>`) | Seed the status map read during `recoverPending()` |
170+
| `rpc.history` | Messages returned by `messages()` (history load) |
171+
| `rpc.pendingPermissionList` | Permissions returned during recovery |
172+
| `rpc.pendingQuestionList` | Questions returned during recovery |
173+
| `rpc.prompts`, `rpc.permissionReplies`, etc. | Call tracking for RPC side-effects |
174+
175+
**String format of `model.toString()`** (used by `assertModel` / `assertSession`):
176+
177+
```
178+
role#msgId
179+
text#partId:
180+
line one
181+
line two
182+
---
183+
tool#partId toolName [STATE] optional title
184+
---
185+
question#id
186+
tool: msgId/callId
187+
header: …
188+
prompt: …
189+
option: label - description
190+
multiple: false
191+
custom: true
192+
---
193+
diff: file1 file2
194+
---
195+
todo: [status] content
196+
---
197+
compacted: N
198+
```
199+
200+
Sections are separated by `---`. Only non-empty sections appear. The status line appended by `SessionController.toString()` is:
201+
202+
```
203+
[agentName] [provider/modelId] [idle|busy|retry|offline|error|awaiting-question|awaiting-permission] [optional detail]
204+
```
205+
77206
## UI Design Guidelines
78207

79208
Official references:

packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import ai.kilocode.backend.util.KiloLog
55
import ai.kilocode.rpc.dto.ChatEventDto
66
import ai.kilocode.rpc.dto.ConfigUpdateDto
77
import ai.kilocode.rpc.dto.MessageWithPartsDto
8+
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
9+
import ai.kilocode.rpc.dto.PermissionReplyDto
10+
import ai.kilocode.rpc.dto.PermissionRequestDto
811
import ai.kilocode.rpc.dto.PromptDto
12+
import ai.kilocode.rpc.dto.QuestionReplyDto
13+
import ai.kilocode.rpc.dto.QuestionRequestDto
914
import kotlinx.coroutines.CoroutineScope
1015
import kotlinx.coroutines.Job
1116
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -38,9 +43,20 @@ class KiloBackendChatManager(
3843
"message.removed",
3944
"message.part.updated",
4045
"message.part.delta",
46+
"message.part.removed",
4147
"session.turn.open",
4248
"session.turn.close",
4349
"session.error",
50+
"session.status",
51+
"session.idle",
52+
"session.compacted",
53+
"session.diff",
54+
"permission.asked",
55+
"permission.replied",
56+
"question.asked",
57+
"question.replied",
58+
"question.rejected",
59+
"todo.updated",
4460
)
4561
}
4662

@@ -174,8 +190,70 @@ class KiloBackendChatManager(
174190
}
175191
}
176192

193+
// ------ permission / question ------
194+
195+
fun replyPermission(requestId: String, dir: String, reply: PermissionReplyDto) {
196+
val body = KiloCliDataParser.buildPermissionReplyJson(reply)
197+
post("/permission/$requestId/reply?directory=${encode(dir)}", body, "replyPermission")
198+
}
199+
200+
fun savePermissionRules(requestId: String, dir: String, rules: PermissionAlwaysRulesDto) {
201+
val body = KiloCliDataParser.buildPermissionAlwaysRulesJson(rules)
202+
post("/permission/$requestId/always-rules?directory=${encode(dir)}", body, "savePermissionRules")
203+
}
204+
205+
fun replyQuestion(requestId: String, dir: String, answers: QuestionReplyDto) {
206+
val body = KiloCliDataParser.buildQuestionReplyJson(answers)
207+
post("/question/$requestId/reply?directory=${encode(dir)}", body, "replyQuestion")
208+
}
209+
210+
fun rejectQuestion(requestId: String, dir: String) {
211+
post("/question/$requestId/reject?directory=${encode(dir)}", "{}", "rejectQuestion")
212+
}
213+
214+
fun pendingPermissions(dir: String): List<PermissionRequestDto> {
215+
val raw = get("/permission?directory=${encode(dir)}", "pendingPermissions") ?: return emptyList()
216+
return KiloCliDataParser.parsePermissionRequests(raw)
217+
}
218+
219+
fun pendingQuestions(dir: String): List<QuestionRequestDto> {
220+
val raw = get("/question?directory=${encode(dir)}", "pendingQuestions") ?: return emptyList()
221+
return KiloCliDataParser.parseQuestionRequests(raw)
222+
}
223+
177224
// ------ utilities ------
178225

226+
private fun post(path: String, body: String, op: String) {
227+
val http = requireClient()
228+
val url = requireBase()
229+
val request = Request.Builder()
230+
.url("$url$path")
231+
.post(body.toRequestBody(JSON_TYPE))
232+
.build()
233+
http.newCall(request).execute().use { response ->
234+
if (!response.isSuccessful) {
235+
log.warn("$op failed: HTTP ${response.code}")
236+
}
237+
}
238+
}
239+
240+
private fun get(path: String, op: String): String? {
241+
val http = requireClient()
242+
val url = requireBase()
243+
val request = Request.Builder()
244+
.url("$url$path")
245+
.get()
246+
.build()
247+
return http.newCall(request).execute().use { response ->
248+
if (!response.isSuccessful) {
249+
log.warn("$op failed: HTTP ${response.code}")
250+
null
251+
} else {
252+
response.body?.string()
253+
}
254+
}
255+
}
256+
179257
private fun requireClient(): OkHttpClient =
180258
client ?: throw IllegalStateException("Chat manager not started")
181259

0 commit comments

Comments
 (0)