git.iko.soy/opencode-android
on 017cb9a help sign in
122 lines 4.9 kB history raw
1package soy.iko.opencode
3import soy.iko.opencode.data.model.AssistantMessage
4import soy.iko.opencode.data.model.BusEvent
5import soy.iko.opencode.data.model.FilePart
6import soy.iko.opencode.data.model.MessagePartUpdated
7import soy.iko.opencode.data.model.MessageWithParts
8import soy.iko.opencode.data.model.Part
9import soy.iko.opencode.data.model.SessionIdle
10import soy.iko.opencode.data.model.TextPart
11import soy.iko.opencode.data.model.ToolCompleted
12import soy.iko.opencode.data.model.ToolPart
13import soy.iko.opencode.data.model.UnknownEvent
14import soy.iko.opencode.data.model.UnknownPart
15import soy.iko.opencode.data.model.sourcePath
16import soy.iko.opencode.data.network.OpencodeJson
17import org.junit.Assert.assertEquals
18import org.junit.Assert.assertNull
19import org.junit.Assert.assertTrue
20import org.junit.Test
22class OpencodeJsonTest {
24 private fun <T> decode(serializer: kotlinx.serialization.KSerializer<T>, json: String): T =
25 OpencodeJson.decodeFromString(serializer, json)
27 @Test
28 fun decodesPromptResponseWithPartsAndTokens() {
29 val json = """
30 {
31 "info": {
32 "role": "assistant", "id": "msg_1", "sessionID": "ses_1",
33 "modelID": "claude-sonnet", "providerID": "anthropic",
34 "cost": 0.012,
35 "tokens": { "input": 10, "output": 20, "reasoning": 0, "cache": { "read": 1, "write": 2 } },
36 "time": { "created": 100, "completed": 200 }
37 },
38 "parts": [
39 { "type": "text", "id": "prt_1", "messageID": "msg_1", "sessionID": "ses_1", "text": "Hello" }
40 ]
41 }
42 """.trimIndent()
44 val msg = decode(MessageWithParts.serializer(), json)
45 val info = msg.info as AssistantMessage
46 assertEquals("anthropic", info.providerID)
47 assertEquals(20L, info.tokens?.output)
48 assertTrue(info.isComplete)
49 assertEquals("Hello", (msg.parts.single() as TextPart).text)
50 }
52 @Test
53 fun decodesToolPartStateMachine() {
54 val json = """
55 { "type": "tool", "id": "prt_2", "messageID": "msg_1", "callID": "c1", "tool": "bash",
56 "state": { "status": "completed", "output": "done", "title": "ran bash" } }
57 """.trimIndent()
58 val part = decode(Part.serializer(), json) as ToolPart
59 assertEquals("bash", part.tool)
60 assertEquals("done", (part.state as ToolCompleted).output)
61 }
63 @Test
64 fun decodesPartUpdatedEvent() {
65 val json = """
66 { "type": "message.part.updated",
67 "properties": { "part": { "type": "text", "id": "prt_1", "messageID": "msg_1", "text": "Hel" } } }
68 """.trimIndent()
69 val event = decode(BusEvent.serializer(), json) as MessagePartUpdated
70 assertEquals("Hel", (event.properties.part as TextPart).text)
71 }
73 @Test
74 fun decodesSessionIdleEvent() {
75 val event = decode(BusEvent.serializer(), """{"type":"session.idle","properties":{"sessionID":"ses_1"}}""")
76 assertTrue(event is SessionIdle)
77 assertEquals("ses_1", (event as SessionIdle).properties.sessionID)
78 }
80 @Test
81 fun unknownEventTypeFallsBackInsteadOfThrowing() {
82 val event = decode(BusEvent.serializer(), """{"type":"some.future.event","properties":{"x":1}}""")
83 assertEquals(UnknownEvent, event)
84 }
86 @Test
87 fun unknownPartTypeFallsBackInsteadOfThrowing() {
88 val part = decode(Part.serializer(), """{"type":"future-part","id":"p9","foo":true}""")
89 assertTrue(part is UnknownPart)
90 }
92 @Test
93 fun decodesFilePartWithObjectSource() {
94 // opencode sends `source` as a structured FilePartSource object, not a string. This must
95 // decode without throwing (a SerializationException here is silently swallowed on the SSE
96 // path, dropping the whole part), and sourcePath must extract the file path.
97 val json = """
98 { "type": "file", "id": "prt_f", "messageID": "msg_1", "mime": "text/plain",
99 "filename": "a.kt",
100 "source": { "type": "file", "path": "src/a.kt",
101 "text": { "value": "x", "start": 0, "end": 1 } } }
102 """.trimIndent()
103 val part = decode(Part.serializer(), json) as FilePart
104 assertEquals("src/a.kt", part.sourcePath)
105 }
107 @Test
108 fun decodesFilePartWithStringSource() {
109 // A bare-string source is still tolerated for backward/other-shape compatibility.
110 val part = decode(
111 Part.serializer(),
112 """{ "type": "file", "id": "prt_f", "source": "/abs/path.txt" }""",
113 ) as FilePart
114 assertEquals("/abs/path.txt", part.sourcePath)
115 }
117 @Test
118 fun filePartWithoutSourceHasNullPath() {
119 val part = decode(Part.serializer(), """{ "type": "file", "id": "prt_f", "url": "/x.png" }""") as FilePart
120 assertNull(part.sourcePath)
121 }