Skip to content

Commit 1781fe3

Browse files
committed
feat: introduce Session domain model and clean up transport layer
feat: add Session domain model mirroring the OpenCode API (id, parentID, title, time, summary, share, FileDiff) feat: rename/delete session
1 parent 48152b6 commit 1781fe3

23 files changed

Lines changed: 387 additions & 189 deletions

src/main/kotlin/com/ashotn/opencode/companion/api/mcp/McpApiClient.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,14 @@ class McpApiClient(
2929

3030
fun connect(port: Int, name: String): ApiResult<Boolean> {
3131
val endpoint = McpEndpoints.connect(name)
32-
val response = transport.postJson(port = port, path = endpoint.path, payload = "{}")
32+
val response = transport.post(port = port, path = endpoint.path, payload = "{}")
3333
return transport.parseBooleanResponse(response, emptyBodyValue = true)
3434
.withParseContext(endpoint)
3535
}
3636

3737
fun disconnect(port: Int, name: String): ApiResult<Boolean> {
3838
val endpoint = McpEndpoints.disconnect(name)
39-
val response = transport.postJson(port = port, path = endpoint.path, payload = "{}")
39+
val response = transport.post(port = port, path = endpoint.path, payload = "{}")
4040
return transport.parseBooleanResponse(response, emptyBodyValue = true)
4141
.withParseContext(endpoint)
4242
}

src/main/kotlin/com/ashotn/opencode/companion/api/permission/PermissionApiClient.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class PermissionApiClient(
1212
fun reply(port: Int, sessionId: String, permissionId: String, response: String): ApiResult<Boolean> {
1313
val endpoint = PermissionEndpoints.reply(sessionId, permissionId)
1414
val payload = JsonObject().also { it.addProperty("response", response) }
15-
val rawResponse = transport.postJson(
15+
val rawResponse = transport.post(
1616
port = port,
1717
path = endpoint.path,
1818
payload = payload.toString(),
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.ashotn.opencode.companion.api.session
2+
3+
data class Session(
4+
val id: String,
5+
val projectID: String?,
6+
val directory: String?,
7+
val parentID: String?,
8+
val title: String?,
9+
val version: String?,
10+
val time: SessionTime,
11+
val summary: SessionSummary?,
12+
val share: SessionShare?,
13+
)
14+
15+
data class SessionTime(
16+
val created: Long,
17+
val updated: Long,
18+
val compacting: Long?,
19+
)
20+
21+
data class SessionSummary(
22+
val additions: Int,
23+
val deletions: Int,
24+
val files: Int,
25+
val diffs: List<FileDiff>?,
26+
)
27+
28+
data class SessionShare(
29+
val url: String?,
30+
)
31+
32+
data class FileDiff(
33+
val file: String,
34+
val before: String,
35+
val after: String,
36+
val additions: Int,
37+
val deletions: Int,
38+
)

src/main/kotlin/com/ashotn/opencode/companion/api/session/SessionApiClient.kt

Lines changed: 100 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.ashotn.opencode.companion.api.session
33
import com.ashotn.opencode.companion.api.transport.ApiError
44
import com.ashotn.opencode.companion.api.transport.ApiResult
55
import com.ashotn.opencode.companion.api.transport.OpenCodeHttpTransport
6+
import com.ashotn.opencode.companion.api.transport.parseBooleanResponse
67
import com.ashotn.opencode.companion.api.transport.mapJsonArrayResponse
78
import com.ashotn.opencode.companion.api.transport.mapJsonObjectResponse
89
import com.ashotn.opencode.companion.api.transport.withParseContext
@@ -26,19 +27,9 @@ class SessionApiClient(
2627
val after: String?,
2728
)
2829

29-
data class HierarchySnapshot(
30-
val sessionIds: Set<String>,
31-
val parentBySessionId: Map<String, String>,
32-
val titleBySessionId: Map<String, String>,
33-
val descriptionBySessionId: Map<String, String>,
34-
val updatedAtBySessionId: Map<String, Long>,
35-
/** Session IDs that have at least one message (server returned a non-null summary). */
36-
val sessionIdsWithMessages: Set<String>,
37-
)
38-
3930
fun createSession(port: Int): ApiResult<CreatedSession> {
4031
val endpoint = SessionEndpoints.create()
41-
val response = transport.postJson(port = port, path = endpoint.path, payload = JsonObject().toString())
32+
val response = transport.post(port = port, path = endpoint.path, payload = JsonObject().toString())
4233
return transport.mapJsonObjectResponse(response) { sessionObj ->
4334
val id = sessionObj.getStringOrNull("id")
4435
if (id.isNullOrBlank()) {
@@ -86,7 +77,12 @@ class SessionApiClient(
8677
}
8778
}
8879

89-
fun fetchFileDiffPreview(port: Int, sessionId: String, projectBase: String, absFilePath: String): ApiResult<FileDiffPreview?> {
80+
fun fetchFileDiffPreview(
81+
port: Int,
82+
sessionId: String,
83+
projectBase: String,
84+
absFilePath: String
85+
): ApiResult<FileDiffPreview?> {
9086
return when (val snapshot = fetchSessionDiffSnapshot(port, sessionId)) {
9187
is ApiResult.Failure -> snapshot
9288
is ApiResult.Success -> {
@@ -99,66 +95,107 @@ class SessionApiClient(
9995
}
10096
}
10197

102-
fun fetchSessionHierarchy(port: Int): ApiResult<HierarchySnapshot> {
98+
fun fetchSessionHierarchy(port: Int): ApiResult<List<Session>> {
10399
val endpoint = SessionEndpoints.list()
104100
val response = transport.get(port = port, path = endpoint.path)
105101
return transport.mapJsonArrayResponse(response) { sessionArray ->
106-
val sessionIds = linkedSetOf<String>()
107-
val parentByChild = HashMap<String, String>()
108-
val titleBySession = HashMap<String, String>()
109-
val descriptionBySession = HashMap<String, String>()
110-
val updatedAtBySession = HashMap<String, Long>()
111-
val sessionIdsWithMessages = linkedSetOf<String>()
112-
113-
sessionArray.forEach { element ->
114-
if (!element.isJsonObject) return@forEach
115-
val sessionObj = element.asJsonObject
116-
val id = sessionObj.getStringOrNull("id")
117-
?: sessionObj.getStringOrNull("sessionID")
118-
?: return@forEach
119-
sessionIds.add(id)
120-
121-
val title = sessionObj.getStringOrNull("title")
122-
if (!title.isNullOrBlank()) {
123-
titleBySession[id] = title
124-
}
125-
126-
val description = sessionObj.getStringOrNull("description")
127-
if (!description.isNullOrBlank()) {
128-
descriptionBySession[id] = description
129-
}
130-
131-
val parent = sessionObj.getStringOrNull("parentID")
132-
if (!parent.isNullOrBlank()) {
133-
parentByChild[id] = parent
102+
val sessions = mutableListOf<Session>()
103+
for (element in sessionArray) {
104+
if (!element.isJsonObject) continue
105+
when (val result = parseSession(element.asJsonObject)) {
106+
is ApiResult.Failure -> return@mapJsonArrayResponse result
107+
is ApiResult.Success -> sessions.add(result.value)
134108
}
109+
}
110+
ApiResult.Success(sessions)
111+
}.withParseContext(endpoint)
112+
}
135113

136-
val timeObj = sessionObj.getObjectOrNull("time")
137-
val updatedAt = timeObj?.get("updated")
138-
?.takeIf { it.isJsonPrimitive }
139-
?.let { runCatching { it.asLong }.getOrNull() }
140-
if (updatedAt != null && updatedAt > 0L) {
141-
updatedAtBySession[id] = updatedAt
142-
}
114+
private fun parseSession(obj: JsonObject): ApiResult<Session> {
115+
val id = obj.getStringOrNull("id")
116+
if (id.isNullOrBlank()) {
117+
return ApiResult.Failure(ApiError.ParseError("Session is missing id"))
118+
}
143119

144-
// A session has messages if the server returns a non-null summary object.
145-
// Brand-new sessions with no messages have no summary field at all.
146-
if (sessionObj.getObjectOrNull("summary") != null) {
147-
sessionIdsWithMessages.add(id)
148-
}
120+
val projectID = obj.getStringOrNull("projectID")
121+
val directory = obj.getStringOrNull("directory")
122+
val parentID = obj.getStringOrNull("parentID")
123+
val title = obj.getStringOrNull("title")
124+
val version = obj.getStringOrNull("version")
125+
126+
val timeObj = obj.getObjectOrNull("time")
127+
val timeCreated = timeObj?.get("created")
128+
?.takeIf { it.isJsonPrimitive }
129+
?.let { runCatching { it.asLong }.getOrNull() } ?: 0L
130+
val timeUpdated = timeObj?.get("updated")
131+
?.takeIf { it.isJsonPrimitive }
132+
?.let { runCatching { it.asLong }.getOrNull() } ?: 0L
133+
val timeCompacting = timeObj?.get("compacting")
134+
?.takeIf { it.isJsonPrimitive }
135+
?.let { runCatching { it.asLong }.getOrNull() }
136+
val sessionTime = SessionTime(created = timeCreated, updated = timeUpdated, compacting = timeCompacting)
137+
138+
val summaryObj = obj.getObjectOrNull("summary")
139+
val summary = if (summaryObj != null) {
140+
val additions = summaryObj.getIntOrNull("additions") ?: 0
141+
val deletions = summaryObj.getIntOrNull("deletions") ?: 0
142+
val files = summaryObj.getIntOrNull("files") ?: 0
143+
val diffsArray = summaryObj.get("diffs")
144+
?.takeIf { it.isJsonArray }
145+
?.asJsonArray
146+
val diffs = diffsArray?.mapNotNull { diffElement ->
147+
if (!diffElement.isJsonObject) return@mapNotNull null
148+
val diffObj = diffElement.asJsonObject
149+
val file = diffObj.getStringOrNull("file") ?: return@mapNotNull null
150+
FileDiff(
151+
file = file,
152+
before = diffObj.getStringOrNull("before") ?: "",
153+
after = diffObj.getStringOrNull("after") ?: "",
154+
additions = diffObj.getIntOrNull("additions") ?: 0,
155+
deletions = diffObj.getIntOrNull("deletions") ?: 0,
156+
)
149157
}
158+
SessionSummary(additions = additions, deletions = deletions, files = files, diffs = diffs)
159+
} else {
160+
null
161+
}
150162

151-
ApiResult.Success(
152-
HierarchySnapshot(
153-
sessionIds = sessionIds,
154-
parentBySessionId = parentByChild,
155-
titleBySessionId = titleBySession,
156-
descriptionBySessionId = descriptionBySession,
157-
updatedAtBySessionId = updatedAtBySession,
158-
sessionIdsWithMessages = sessionIdsWithMessages,
159-
)
163+
val shareObj = obj.getObjectOrNull("share")
164+
val share = if (shareObj != null) {
165+
SessionShare(url = shareObj.getStringOrNull("url"))
166+
} else {
167+
null
168+
}
169+
170+
return ApiResult.Success(
171+
Session(
172+
id = id,
173+
projectID = projectID,
174+
directory = directory,
175+
parentID = parentID,
176+
title = title,
177+
version = version,
178+
time = sessionTime,
179+
summary = summary,
180+
share = share,
160181
)
161-
}.withParseContext(endpoint)
182+
)
183+
}
184+
185+
fun deleteSession(port: Int, sessionId: String): ApiResult<Boolean> {
186+
val endpoint = SessionEndpoints.delete(sessionId)
187+
val response = transport.delete(port = port, path = endpoint.path)
188+
return transport.parseBooleanResponse(response).withParseContext(endpoint)
189+
}
190+
191+
fun updateSession(port: Int, sessionId: String, title: String): ApiResult<Session> {
192+
val endpoint = SessionEndpoints.update(sessionId)
193+
val payload = JsonObject().apply {
194+
addProperty("title", title)
195+
}
196+
val response = transport.patch(port = port, path = endpoint.path, payload = payload.toString())
197+
return transport.mapJsonObjectResponse(response) { parseSession(it) }
198+
.withParseContext(endpoint)
162199
}
163200

164201
}

src/main/kotlin/com/ashotn/opencode/companion/api/session/SessionEndpoints.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,8 @@ internal object SessionEndpoints {
99
fun list(): ApiEndpoint = ApiEndpoint(method = HttpMethod.GET, path = "/session")
1010

1111
fun diff(sessionId: String): ApiEndpoint = ApiEndpoint(method = HttpMethod.GET, path = "/session/$sessionId/diff")
12+
13+
fun delete(sessionId: String): ApiEndpoint = ApiEndpoint(method = HttpMethod.DELETE, path = "/session/$sessionId")
14+
15+
fun update(sessionId: String): ApiEndpoint = ApiEndpoint(method = HttpMethod.PATCH, path = "/session/$sessionId")
1216
}

src/main/kotlin/com/ashotn/opencode/companion/api/transport/ApiEndpoint.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.ashotn.opencode.companion.api.transport
33
enum class HttpMethod {
44
GET,
55
POST,
6+
DELETE,
7+
PATCH,
68
}
79

810
data class ApiEndpoint(

src/main/kotlin/com/ashotn/opencode/companion/api/transport/OpenCodeHttpTransport.kt

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class OpenCodeHttpTransport(
3232
timeouts = timeouts,
3333
)
3434

35-
fun postJson(
35+
fun post(
3636
port: Int,
3737
path: String,
3838
payload: String,
@@ -48,6 +48,37 @@ class OpenCodeHttpTransport(
4848
timeouts = timeouts,
4949
)
5050

51+
fun patch(
52+
port: Int,
53+
path: String,
54+
payload: String,
55+
timeouts: Timeouts? = null,
56+
accept: String = APPLICATION_JSON,
57+
): ApiResult<String?> = execute(
58+
method = "PATCH",
59+
port = port,
60+
path = path,
61+
payload = payload,
62+
contentType = APPLICATION_JSON,
63+
accept = accept,
64+
timeouts = timeouts,
65+
)
66+
67+
fun delete(
68+
port: Int,
69+
path: String,
70+
timeouts: Timeouts? = null,
71+
accept: String = APPLICATION_JSON,
72+
): ApiResult<String?> = execute(
73+
method = "DELETE",
74+
port = port,
75+
path = path,
76+
payload = null,
77+
contentType = null,
78+
accept = accept,
79+
timeouts = timeouts,
80+
)
81+
5182
fun parseJsonObject(body: String?): ApiResult<JsonObject> {
5283
if (body.isNullOrBlank()) {
5384
return ApiResult.Failure(ApiError.ParseError("Response body is empty"))

src/main/kotlin/com/ashotn/opencode/companion/api/tui/TuiApiClient.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ class TuiApiClient(
1212
fun appendPrompt(port: Int, text: String): ApiResult<Boolean> {
1313
val endpoint = TuiEndpoints.appendPrompt()
1414
val payload = JsonObject().also { it.addProperty("text", text) }
15-
val response = transport.postJson(port = port, path = endpoint.path, payload = payload.toString())
15+
val response = transport.post(port = port, path = endpoint.path, payload = payload.toString())
1616
return transport.parseBooleanResponse(response).withParseContext(endpoint)
1717
}
1818

1919
fun selectSession(port: Int, sessionId: String): ApiResult<Boolean> {
2020
val endpoint = TuiEndpoints.selectSession()
2121
val payload = JsonObject().also { it.addProperty("sessionID", sessionId) }
22-
val response = transport.postJson(port = port, path = endpoint.path, payload = payload.toString())
22+
val response = transport.post(port = port, path = endpoint.path, payload = payload.toString())
2323
return transport.parseBooleanResponse(response).withParseContext(endpoint)
2424
}
2525
}

src/main/kotlin/com/ashotn/opencode/companion/diff/DiffHunkComputer.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.ashotn.opencode.companion.diff
22

3-
import com.ashotn.opencode.companion.ipc.FileDiff
3+
import com.ashotn.opencode.companion.api.session.FileDiff
44
import com.intellij.diff.comparison.ComparisonManager
55
import com.intellij.diff.comparison.ComparisonPolicy
66
import com.intellij.openapi.diagnostic.Logger

src/main/kotlin/com/ashotn/opencode/companion/diff/DiffQueryService.kt

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.ashotn.opencode.companion.diff
22

3+
import com.ashotn.opencode.companion.api.session.Session
4+
35
internal class DiffQueryService {
46
fun visibleFiles(
57
familySessionIds: () -> Set<String>,
@@ -65,29 +67,26 @@ internal class DiffQueryService {
6567

6668
fun listSessions(
6769
knownSessionIds: Set<String>,
68-
parentBySessionId: Map<String, String>,
69-
titleBySessionId: Map<String, String>,
70-
descriptionBySessionId: Map<String, String>,
70+
sessions: Map<String, Session>,
7171
busyBySession: Map<String, Boolean>,
7272
hunksBySessionAndFile: Map<String, Map<String, List<DiffHunk>>>,
7373
addedBySession: Map<String, Set<String>>,
7474
deletedBySession: Map<String, Set<String>>,
7575
updatedAtBySession: Map<String, Long>,
76-
sessionIdsWithMessages: Set<String>,
7776
): List<OpenCodeDiffService.SessionInfo> {
7877
return knownSessionIds
7978
.map { sessionId ->
79+
val session = sessions[sessionId]
8080
OpenCodeDiffService.SessionInfo(
8181
sessionId = sessionId,
82-
parentSessionId = parentBySessionId[sessionId],
83-
title = titleBySessionId[sessionId],
84-
description = descriptionBySessionId[sessionId],
82+
parentSessionId = session?.parentID,
83+
title = session?.title ?: sessionId.take(12),
8584
isBusy = busyBySession[sessionId] == true,
8685
trackedFileCount = ((hunksBySessionAndFile[sessionId]?.keys ?: emptySet()) +
8786
(addedBySession[sessionId] ?: emptySet()) +
8887
(deletedBySession[sessionId] ?: emptySet())).size,
8988
updatedAtMillis = updatedAtBySession[sessionId] ?: 0L,
90-
hasMessages = sessionId in sessionIdsWithMessages,
89+
hasMessages = session?.summary != null,
9190
)
9291
}
9392
.sortedWith(

0 commit comments

Comments
 (0)