|
74 | 74 | - Service classes ↔ `<applicationService>`/`<projectService>` entries in the corresponding module XML |
75 | 75 | - `script/build.ts` platform list ↔ `backend/build.gradle.kts` `requiredPlatforms` list |
76 | 76 |
|
| 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 | + |
77 | 206 | ## UI Design Guidelines |
78 | 207 |
|
79 | 208 | Official references: |
|
0 commit comments