1package soy.iko.opencode.data.repo
3import soy.iko.opencode.data.model.AssistantMessage
4import soy.iko.opencode.data.model.BusEvent
5import soy.iko.opencode.data.model.MessagePartRemoved
6import soy.iko.opencode.data.model.MessagePartUpdated
7import soy.iko.opencode.data.model.MessageRemoved
8import soy.iko.opencode.data.model.MessageUpdated
9import soy.iko.opencode.data.model.MessageWithParts
10import soy.iko.opencode.data.model.ModelRef
11import soy.iko.opencode.data.model.Part
12import soy.iko.opencode.data.model.SessionError
13import soy.iko.opencode.data.model.SessionIdle
14import soy.iko.opencode.data.network.EventStreamClient
15import soy.iko.opencode.data.network.OpencodeApiClient
16import kotlinx.coroutines.flow.Flow
17import kotlinx.coroutines.flow.channelFlow
18import kotlinx.coroutines.launch
19import kotlinx.coroutines.sync.Mutex
20import kotlinx.coroutines.sync.withLock
23 * Source of truth for a session's conversation. It seeds from `GET /session/:id/message`
24 * and then reduces [BusEvent]s from the SSE stream into an ordered list of messages,
25 * each keyed by message id with parts keyed by part id (idempotent upserts).
27class SessionRepository(
28 private val api: OpencodeApiClient,
29 private val eventStream: EventStreamClient,
31 suspend fun listSessions() = api.listSessions()
32 suspend fun createSession(title: String? = null) = api.createSession(title)
33 suspend fun deleteSession(id: String) = api.deleteSession(id)
34 suspend fun abort(sessionId: String) = api.abort(sessionId)
36 suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?, agent: String? = null) =
37 api.sendPrompt(sessionId, text, model, agent)
40 * A live, ordered view of [sessionId]'s messages. Begins collecting the event
41 * stream before the initial REST load so no streamed part is missed, then reduces
42 * events into the in-memory state.
44 fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
45 val store = MessageStore()
48 suspend fun publish() = send(store.snapshot())
50 // Subscribe to events first so we don't miss early deltas during the initial load.
52 eventStream.events.collect { event ->
54 if (store.reduce(sessionId, event)) publish()
59 val initial = runCatching { api.listMessages(sessionId) }.getOrDefault(emptyList())
65 // Keep the flow alive until the collector cancels; the launched job is torn down with it.
69 /** In-memory reduction state for one observed session. Not thread-safe; guard with a Mutex. */
70 private class MessageStore {
71 // messageId -> (info + parts), insertion-ordered.
72 private val messages = LinkedHashMap<String, MessageWithParts>()
74 fun snapshot(): List<MessageWithParts> = messages.values.toList()
76 fun seed(initial: List<MessageWithParts>) {
77 for (m in initial) messages[m.info.id] = m
80 /** Returns true if the state changed (and a new snapshot should be published). */
81 fun reduce(sessionId: String, event: BusEvent): Boolean {
83 is MessageUpdated -> {
84 val info = event.properties.info
85 if (info.sessionID != sessionId) {
88 val existing = messages[info.id]
89 messages[info.id] = existing?.copy(info = info) ?: MessageWithParts(info)
94 is MessagePartUpdated -> {
95 val part = event.properties.part
96 val messageId = part.messageID ?: event.properties.messageID
97 val partSession = part.sessionID ?: event.properties.sessionID
98 if (messageId == null || (partSession != null && partSession != sessionId)) {
101 upsertPart(messageId, part)
105 is MessagePartRemoved -> {
106 val messageId = event.properties.messageID
107 val partId = event.properties.partID
108 if (messageId == null || partId == null) false else removePart(messageId, partId)
111 is MessageRemoved -> {
112 val id = event.properties.messageID
113 if (id == null) false else messages.remove(id) != null
120 private fun upsertPart(messageId: String, part: Part): Boolean {
121 val current = messages[messageId]
122 val parts = current?.parts.orEmpty()
123 val idx = parts.indexOfFirst { it.id == part.id }
124 val newParts = if (idx >= 0) {
125 parts.toMutableList().also { it[idx] = part }
129 messages[messageId] = current?.copy(parts = newParts)
131 info = AssistantMessage(id = messageId, sessionID = part.sessionID ?: ""),
137 private fun removePart(messageId: String, partId: String): Boolean {
138 val current = messages[messageId] ?: return false
139 val newParts = current.parts.filterNot { it.id == partId }
140 if (newParts.size == current.parts.size) return false
141 messages[messageId] = current.copy(parts = newParts)
147 /** Convenience: is this event a run-completion signal for [sessionId]? */
148 fun isIdle(event: BusEvent, sessionId: String): Boolean =
149 event is SessionIdle && (event.properties.sessionID == null || event.properties.sessionID == sessionId)
151 fun isError(event: BusEvent, sessionId: String): Boolean =
152 event is SessionError && (event.properties.sessionID == null || event.properties.sessionID == sessionId)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list