git.iko.soy/opencode-android
on master help sign in
Add live task plan (todowrite) with collapsible progress checklist
aeac232iko
app/src/main/java/soy/iko/opencode/data/model/Todo.ktnew file+84
@@ -0,0 +1,84 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.builtins.ListSerializer
6+import kotlinx.serialization.json.Json
7+import kotlinx.serialization.json.JsonElement
8+
9+/** The tool name opencode uses to write the agent's task plan. */
10+const val TODO_WRITE_TOOL = "todowrite"
11+
12+/**
13+ * A single entry in an agent's task plan, as written by opencode's `todowrite` tool.
14+ * Every field is defaulted and decoding ignores unknown keys (see [parseTodos]) so a
15+ * change to the tool's payload shape degrades to a best-effort render instead of
16+ * dropping the whole plan.
17+ */
18+@Immutable
19+@Serializable
20+data class TodoItem(
21+ val id: String = "",
22+ val content: String = "",
23+ val status: String = "pending",
24+ val priority: String? = null,
25+)
26+
27+/** Normalized lifecycle of a [TodoItem]; [UNKNOWN] covers any status the client doesn't model. */
28+enum class TodoStatus { PENDING, IN_PROGRESS, COMPLETED, CANCELLED, UNKNOWN }
29+
30+fun TodoItem.statusEnum(): TodoStatus = when (status.trim().lowercase()) {
31+ "pending", "todo", "" -> TodoStatus.PENDING
32+ "in_progress", "in-progress", "inprogress", "running", "active" -> TodoStatus.IN_PROGRESS
33+ "completed", "complete", "done" -> TodoStatus.COMPLETED
34+ "cancelled", "canceled", "skipped" -> TodoStatus.CANCELLED
35+ else -> TodoStatus.UNKNOWN
36+}
37+
38+@Serializable
39+private data class TodoWriteInput(val todos: List<TodoItem> = emptyList())
40+
41+// A lenient decoder mirroring OpencodeJson's resilience (unknown keys ignored, nulls
42+// coerced to defaults) without pulling the network layer into data/model.
43+private val todoJson = Json {
44+ ignoreUnknownKeys = true
45+ isLenient = true
46+ coerceInputValues = true
47+ explicitNulls = false
48+}
49+
50+/**
51+ * Extract the todo list from a `todowrite` tool call's input JSON. The tool sends the
52+ * full list on every write, so a single call's input is a complete plan snapshot.
53+ * Accepts either the documented `{ "todos": [...] }` object or a bare array, and returns
54+ * an empty list on anything unparseable.
55+ */
56+fun parseTodos(input: JsonElement?): List<TodoItem> {
57+ if (input == null) return emptyList()
58+ return runCatching {
59+ todoJson.decodeFromJsonElement(TodoWriteInput.serializer(), input).todos
60+ }.recoverCatching {
61+ todoJson.decodeFromJsonElement(ListSerializer(TodoItem.serializer()), input)
62+ }.getOrDefault(emptyList())
63+}
64+
65+/**
66+ * The current task plan for a conversation: the todos of the most recent `todowrite`
67+ * tool call that carries a non-empty, parseable list. opencode rewrites the whole list
68+ * on every write, so a single call is a complete snapshot; scanning newest-first and
69+ * skipping empties avoids a mid-stream write (input not yet arrived) briefly blanking a
70+ * plan that's still valid.
71+ */
72+fun currentTodoPlan(messages: List<MessageWithParts>): List<TodoItem> {
73+ for (mi in messages.indices.reversed()) {
74+ val parts = messages[mi].parts
75+ for (pi in parts.indices.reversed()) {
76+ val part = parts[pi]
77+ if (part is ToolPart && part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) {
78+ val todos = parseTodos(part.state.inputElement())
79+ if (todos.isNotEmpty()) return todos
80+ }
81+ }
82+ }
83+ return emptyList()
84+}
app/src/main/java/soy/iko/opencode/data/model/ToolState.kt+9
@@ -51,3 +51,12 @@ data class ToolError(
51@Serializable
51@Serializable
52@SerialName("__unknown")
52@SerialName("__unknown")
53data object ToolUnknown : ToolState
53data object ToolUnknown : ToolState
54+
55+/** The raw input arguments carried by a tool call, present on the running/completed/error
56+ * states (pending/unknown carry none). */
57+fun ToolState.inputElement(): JsonElement? = when (this) {
58+ is ToolRunning -> input
59+ is ToolCompleted -> input
60+ is ToolError -> input
61+ else -> null
62+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+4
@@ -567,7 +567,11 @@ fun ChatScreen(
567 snackbarHost = { SnackbarHost(snackbar) },
567 snackbarHost = { SnackbarHost(snackbar) },
568 bottomBar = {
568 bottomBar = {
569 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
569 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
570+ val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
570 Column {
571 Column {
572+ // Pinned above the composer so the agent's plan and progress stay visible
573+ // while scrolled up mid-run; renders nothing when there's no plan.
574+ TodoPlanBar(todoPlan)
571 AnimatedVisibility(visible = reverted) {
575 AnimatedVisibility(visible = reverted) {
572 RevertBanner(onUndo = { vm.unrevert() })
576 RevertBanner(onUndo = { vm.unrevert() })
573 }
577 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+15
@@ -14,6 +14,8 @@ import soy.iko.opencode.data.model.PermissionUpdated
14import soy.iko.opencode.data.model.SessionDeleted
14import soy.iko.opencode.data.model.SessionDeleted
15import soy.iko.opencode.data.model.SessionUpdated
15import soy.iko.opencode.data.model.SessionUpdated
16import soy.iko.opencode.data.model.TextPart
16import soy.iko.opencode.data.model.TextPart
17+import soy.iko.opencode.data.model.TodoItem
18+import soy.iko.opencode.data.model.currentTodoPlan
17import soy.iko.opencode.data.model.defaultOption
19import soy.iko.opencode.data.model.defaultOption
18import soy.iko.opencode.data.model.toOptions
20import soy.iko.opencode.data.model.toOptions
19import soy.iko.opencode.data.network.EventStreamClient
21import soy.iko.opencode.data.network.EventStreamClient
@@ -36,6 +38,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
36import kotlinx.coroutines.CancellationException
38import kotlinx.coroutines.CancellationException
37import kotlinx.coroutines.flow.collectLatest
39import kotlinx.coroutines.flow.collectLatest
38import kotlinx.coroutines.flow.debounce
40import kotlinx.coroutines.flow.debounce
41+import kotlinx.coroutines.flow.distinctUntilChanged
39import kotlinx.coroutines.flow.drop
42import kotlinx.coroutines.flow.drop
40import kotlinx.coroutines.flow.first
43import kotlinx.coroutines.flow.first
41import kotlinx.coroutines.flow.filterIsInstance
44import kotlinx.coroutines.flow.filterIsInstance
@@ -198,6 +201,18 @@ class ChatViewModel(
198 initialValue = false,
201 initialValue = false,
199 )
202 )
200
203
204+ /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
205+ * live progress checklist above the composer. distinctUntilChanged so a per-token messages
206+ * emission that doesn't change the plan doesn't recompose the bar. */
207+ val todoPlan: StateFlow<List<TodoItem>> = messages
208+ .map { currentTodoPlan(it) }
209+ .distinctUntilChanged()
210+ .stateIn(
211+ scope = viewModelScope,
212+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
213+ initialValue = emptyList(),
214+ )
215+
201 private val _running = MutableStateFlow(false)
216 private val _running = MutableStateFlow(false)
202 val running: StateFlow<Boolean> = _running.asStateFlow()
217 val running: StateFlow<Boolean> = _running.asStateFlow()
203
218
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+29−25
@@ -59,7 +59,10 @@ import soy.iko.opencode.data.model.ToolPending
59import soy.iko.opencode.data.model.ToolRunning
59import soy.iko.opencode.data.model.ToolRunning
60import soy.iko.opencode.data.model.ToolState
60import soy.iko.opencode.data.model.ToolState
61import soy.iko.opencode.data.model.ToolUnknown
61import soy.iko.opencode.data.model.ToolUnknown
62+import soy.iko.opencode.data.model.TODO_WRITE_TOOL
62import soy.iko.opencode.data.model.UnknownPart
63import soy.iko.opencode.data.model.UnknownPart
64+import soy.iko.opencode.data.model.inputElement
65+import soy.iko.opencode.data.model.parseTodos
63import soy.iko.opencode.ui.components.DiffView
66import soy.iko.opencode.ui.components.DiffView
64import soy.iko.opencode.ui.components.ImageLoadContext
67import soy.iko.opencode.ui.components.ImageLoadContext
65import soy.iko.opencode.ui.components.MarkdownText
68import soy.iko.opencode.ui.components.MarkdownText
@@ -83,14 +86,6 @@ private fun ToolState.titleText(): String? = when (this) {
83 else -> null
86 else -> null
84}
87}
85
88
86−/** Extract the raw input arguments of a tool call, if any. */
87−private fun ToolState.inputJson(): JsonElement? = when (this) {
88− is ToolRunning -> input
89− is ToolCompleted -> input
90− is ToolError -> input
91− else -> null
92−}
93−
94/**
89/**
95 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
90 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
96 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
91 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
@@ -251,24 +246,33 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
251 modifier = Modifier.padding(start = 22.dp, top = 2.dp),
246 modifier = Modifier.padding(start = 22.dp, top = 2.dp),
252 )
247 )
253 }
248 }
249+ // A todowrite call carries the agent's task plan as its input; render it as a
250+ // checklist rather than raw JSON so each step's progress reads at a glance.
251+ val planTodos = remember(part) {
252+ if (part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) parseTodos(part.state.inputElement()) else emptyList()
253+ }
254+ if (planTodos.isNotEmpty()) {
255+ TodoPlanChecklist(planTodos, modifier = Modifier.padding(top = 8.dp))
256+ } else {
254− // The tool's arguments (pretty-printed JSON), collapsible. Rendered before the
257+ // The tool's arguments (pretty-printed JSON), collapsible. Rendered before the
255− // output so the call reads top-to-bottom: name → what → input → result.
258+ // output so the call reads top-to-bottom: name → what → input → result.
256− part.state.inputJson()?.let { input ->
259+ part.state.inputElement()?.let { input ->
257− val inputLabel = stringResource(R.string.tool_input)
260+ val inputLabel = stringResource(R.string.tool_input)
258− val pretty = remember(input) {
261+ val pretty = remember(input) {
259− runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
262+ runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
263+ }
264+ CollapsibleDetail(
265+ label = inputLabel,
266+ detail = pretty,
267+ isDiff = false,
268+ keySuffix = "input",
269+ // The input block has a label row whose collapse toggle announces its state
270+ // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
271+ // rather than an empty state (the defaults are "").
272+ expandedState = stringResource(R.string.state_expanded),
273+ collapsedState = stringResource(R.string.state_collapsed),
274+ )
260 }
275 }
261− CollapsibleDetail(
262− label = inputLabel,
263− detail = pretty,
264− isDiff = false,
265− keySuffix = "input",
266− // The input block has a label row whose collapse toggle announces its state
267− // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
268− // rather than an empty state (the defaults are "").
269− expandedState = stringResource(R.string.state_expanded),
270− collapsedState = stringResource(R.string.state_collapsed),
271− )
272 }
276 }
273 val detail = when (val s = part.state) {
277 val detail = when (val s = part.state) {
274 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
278 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
app/src/main/java/soy/iko/opencode/ui/chat/TodoPlan.ktnew file+165
@@ -0,0 +1,165 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.foundation.clickable
5+import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
9+import androidx.compose.foundation.layout.defaultMinSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.heightIn
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.rememberScrollState
15+import androidx.compose.foundation.verticalScroll
16+import androidx.compose.material.icons.Icons
17+import androidx.compose.material.icons.filled.Autorenew
18+import androidx.compose.material.icons.filled.Cancel
19+import androidx.compose.material.icons.filled.CheckCircle
20+import androidx.compose.material.icons.filled.Checklist
21+import androidx.compose.material.icons.filled.ExpandLess
22+import androidx.compose.material.icons.filled.ExpandMore
23+import androidx.compose.material.icons.filled.RadioButtonUnchecked
24+import androidx.compose.material3.Icon
25+import androidx.compose.material3.MaterialTheme
26+import androidx.compose.material3.Surface
27+import androidx.compose.material3.Text
28+import androidx.compose.runtime.Composable
29+import androidx.compose.runtime.getValue
30+import androidx.compose.runtime.mutableStateOf
31+import androidx.compose.runtime.remember
32+import androidx.compose.runtime.saveable.rememberSaveable
33+import androidx.compose.runtime.setValue
34+import androidx.compose.ui.Alignment
35+import androidx.compose.ui.Modifier
36+import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.Role
38+import androidx.compose.ui.semantics.semantics
39+import androidx.compose.ui.semantics.stateDescription
40+import androidx.compose.ui.text.font.FontWeight
41+import androidx.compose.ui.text.style.TextDecoration
42+import androidx.compose.ui.unit.dp
43+import soy.iko.opencode.R
44+import soy.iko.opencode.data.model.TodoItem
45+import soy.iko.opencode.data.model.TodoStatus
46+import soy.iko.opencode.data.model.statusEnum
47+
48+/** Renders a task plan as a checklist: a status icon per row plus its description, with
49+ * completed/cancelled items struck through and the in-progress item emphasized. */
50+@Composable
51+fun TodoPlanChecklist(todos: List<TodoItem>, modifier: Modifier = Modifier) {
52+ Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
53+ for (todo in todos) TodoRow(todo)
54+ }
55+}
56+
57+@Composable
58+private fun TodoRow(todo: TodoItem) {
59+ val status = todo.statusEnum()
60+ val struck = status == TodoStatus.COMPLETED || status == TodoStatus.CANCELLED
61+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
62+ TodoStatusIcon(status)
63+ Text(
64+ todo.content,
65+ style = MaterialTheme.typography.bodySmall,
66+ color = when {
67+ status == TodoStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
68+ struck -> MaterialTheme.colorScheme.onSurfaceVariant
69+ else -> MaterialTheme.colorScheme.onSurface
70+ },
71+ fontWeight = if (status == TodoStatus.IN_PROGRESS) FontWeight.Medium else null,
72+ textDecoration = if (struck) TextDecoration.LineThrough else null,
73+ modifier = Modifier.padding(start = 8.dp),
74+ )
75+ }
76+}
77+
78+@Composable
79+private fun TodoStatusIcon(status: TodoStatus) {
80+ val icon = when (status) {
81+ TodoStatus.COMPLETED -> Icons.Filled.CheckCircle
82+ TodoStatus.IN_PROGRESS -> Icons.Filled.Autorenew
83+ TodoStatus.CANCELLED -> Icons.Filled.Cancel
84+ else -> Icons.Filled.RadioButtonUnchecked
85+ }
86+ val tint = when (status) {
87+ TodoStatus.COMPLETED, TodoStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
88+ else -> MaterialTheme.colorScheme.onSurfaceVariant
89+ }
90+ val desc = when (status) {
91+ TodoStatus.COMPLETED -> R.string.todo_completed
92+ TodoStatus.IN_PROGRESS -> R.string.todo_in_progress
93+ TodoStatus.CANCELLED -> R.string.todo_cancelled
94+ else -> R.string.todo_pending
95+ }
96+ Icon(
97+ icon,
98+ contentDescription = stringResource(desc),
99+ tint = tint,
100+ modifier = Modifier.size(16.dp),
101+ )
102+}
103+
104+/** A collapsible plan bar pinned above the composer: the header shows completed/total and
105+ * a chevron; expanding reveals the full (scrollable, height-capped) checklist. Renders
106+ * nothing when there's no plan, so the caller can place it unconditionally. */
107+@Composable
108+fun TodoPlanBar(todos: List<TodoItem>, modifier: Modifier = Modifier) {
109+ if (todos.isEmpty()) return
110+ var expanded by rememberSaveable { mutableStateOf(false) }
111+ val completed = remember(todos) { todos.count { it.statusEnum() == TodoStatus.COMPLETED } }
112+ val expandedState = stringResource(R.string.state_expanded)
113+ val collapsedState = stringResource(R.string.state_collapsed)
114+ Surface(
115+ color = MaterialTheme.colorScheme.surfaceVariant,
116+ tonalElevation = 2.dp,
117+ modifier = modifier.fillMaxWidth(),
118+ ) {
119+ Column {
120+ Row(
121+ modifier = Modifier
122+ .fillMaxWidth()
123+ .defaultMinSize(minHeight = 44.dp)
124+ .clickable(role = Role.Button) { expanded = !expanded }
125+ .semantics { stateDescription = if (expanded) expandedState else collapsedState }
126+ .padding(horizontal = 12.dp, vertical = 6.dp),
127+ verticalAlignment = Alignment.CenterVertically,
128+ ) {
129+ Icon(
130+ Icons.Filled.Checklist,
131+ contentDescription = null,
132+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
133+ modifier = Modifier.size(18.dp),
134+ )
135+ Text(
136+ stringResource(R.string.plan),
137+ style = MaterialTheme.typography.labelLarge,
138+ modifier = Modifier.padding(start = 8.dp),
139+ )
140+ Text(
141+ stringResource(R.string.plan_progress, completed, todos.size),
142+ style = MaterialTheme.typography.labelMedium,
143+ color = MaterialTheme.colorScheme.onSurfaceVariant,
144+ modifier = Modifier.padding(start = 8.dp),
145+ )
146+ Spacer(Modifier.weight(1f))
147+ Icon(
148+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
149+ contentDescription = null,
150+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
151+ modifier = Modifier.size(20.dp),
152+ )
153+ }
154+ AnimatedVisibility(visible = expanded) {
155+ TodoPlanChecklist(
156+ todos,
157+ modifier = Modifier
158+ .heightIn(max = 240.dp)
159+ .verticalScroll(rememberScrollState())
160+ .padding(start = 12.dp, end = 12.dp, bottom = 10.dp),
161+ )
162+ }
163+ }
164+ }
165+}
app/src/main/res/values-es/strings.xml+8
@@ -359,4 +359,12 @@
359 <string name="search_all_hint">Buscar en todas las conversaciones…</string>
359 <string name="search_all_hint">Buscar en todas las conversaciones…</string>
360 <string name="search_no_matches">No hay mensajes coincidentes</string>
360 <string name="search_no_matches">No hay mensajes coincidentes</string>
361 <string name="search_truncated">Mostrando resultados solo de las sesiones más recientes.</string>
361 <string name="search_truncated">Mostrando resultados solo de las sesiones más recientes.</string>
362+
363+ <!-- Plan de tareas del agente (herramienta todowrite) -->
364+ <string name="plan">Plan</string>
365+ <string name="plan_progress">%1$d/%2$d</string>
366+ <string name="todo_pending">Pendiente</string>
367+ <string name="todo_in_progress">En curso</string>
368+ <string name="todo_completed">Completado</string>
369+ <string name="todo_cancelled">Cancelado</string>
362</resources>
370</resources>
app/src/main/res/values/strings.xml+8
@@ -361,4 +361,12 @@
361 <string name="app_lock_prompt_subtitle">Authenticate to access your servers.</string>
361 <string name="app_lock_prompt_subtitle">Authenticate to access your servers.</string>
362 <string name="app_locked">opencode is locked</string>
362 <string name="app_locked">opencode is locked</string>
363 <string name="app_unlock">Unlock</string>
363 <string name="app_unlock">Unlock</string>
364+
365+ <!-- Agent task plan (todowrite tool) -->
366+ <string name="plan">Plan</string>
367+ <string name="plan_progress">%1$d/%2$d</string>
368+ <string name="todo_pending">To do</string>
369+ <string name="todo_in_progress">In progress</string>
370+ <string name="todo_completed">Completed</string>
371+ <string name="todo_cancelled">Cancelled</string>
364</resources>
372</resources>
app/src/test/java/soy/iko/opencode/data/model/TodoPlanTest.ktnew file+90
@@ -0,0 +1,90 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.json.Json
4+import org.junit.Assert.assertEquals
5+import org.junit.Assert.assertTrue
6+import org.junit.Test
7+
8+class TodoPlanTest {
9+ private val json = Json { ignoreUnknownKeys = true; isLenient = true }
10+ private fun element(s: String) = json.parseToJsonElement(s)
11+
12+ @Test
13+ fun parsesTodosFromObject() {
14+ val todos = parseTodos(
15+ element("""{"todos":[{"id":"1","content":"A","status":"completed"},{"id":"2","content":"B","status":"in_progress"}]}"""),
16+ )
17+ assertEquals(2, todos.size)
18+ assertEquals("A", todos[0].content)
19+ assertEquals(TodoStatus.COMPLETED, todos[0].statusEnum())
20+ assertEquals(TodoStatus.IN_PROGRESS, todos[1].statusEnum())
21+ }
22+
23+ @Test
24+ fun parsesTodosFromBareArray() {
25+ val todos = parseTodos(element("""[{"content":"only","status":"pending"}]"""))
26+ assertEquals(1, todos.size)
27+ assertEquals(TodoStatus.PENDING, todos[0].statusEnum())
28+ }
29+
30+ @Test
31+ fun ignoresUnknownKeysAndUnknownStatus() {
32+ val todos = parseTodos(element("""{"todos":[{"content":"x","status":"weird","extra":123}]}"""))
33+ assertEquals(1, todos.size)
34+ assertEquals("x", todos[0].content)
35+ assertEquals(TodoStatus.UNKNOWN, todos[0].statusEnum())
36+ }
37+
38+ @Test
39+ fun returnsEmptyForNullAndGarbage() {
40+ assertTrue(parseTodos(null).isEmpty())
41+ assertTrue(parseTodos(element(""""just a string"""")).isEmpty())
42+ assertTrue(parseTodos(element("""{"nope":true}""")).isEmpty())
43+ }
44+
45+ @Test
46+ fun statusEnumNormalizesAliases() {
47+ assertEquals(TodoStatus.IN_PROGRESS, TodoItem(status = "in-progress").statusEnum())
48+ assertEquals(TodoStatus.COMPLETED, TodoItem(status = "DONE").statusEnum())
49+ assertEquals(TodoStatus.CANCELLED, TodoItem(status = "canceled").statusEnum())
50+ assertEquals(TodoStatus.PENDING, TodoItem(status = "").statusEnum())
51+ }
52+
53+ @Test
54+ fun currentPlanPicksLatestNonEmptyTodowrite() {
55+ val messages = listOf(
56+ message("m1", todoPart("p1", """{"todos":[{"content":"old","status":"pending"}]}""")),
57+ message("m2", todoPart("p2", """{"todos":[{"content":"new1","status":"completed"},{"content":"new2","status":"pending"}]}""")),
58+ message("m3", TextPart(id = "t", text = "just talking")),
59+ )
60+ val plan = currentTodoPlan(messages)
61+ assertEquals(2, plan.size)
62+ assertEquals("new1", plan[0].content)
63+ }
64+
65+ @Test
66+ fun currentPlanSkipsEmptyLatestForPriorNonEmpty() {
67+ val messages = listOf(
68+ message("m1", todoPart("p1", """{"todos":[{"content":"keep","status":"pending"}]}""")),
69+ message("m2", todoPart("p2", """{"todos":[]}""")),
70+ )
71+ val plan = currentTodoPlan(messages)
72+ assertEquals(1, plan.size)
73+ assertEquals("keep", plan[0].content)
74+ }
75+
76+ @Test
77+ fun currentPlanEmptyWhenNoTodowrite() {
78+ val messages = listOf(
79+ message("m1", TextPart(id = "t", text = "hi")),
80+ message("m2", ToolPart(id = "b", tool = "bash", state = ToolCompleted(output = "done"))),
81+ )
82+ assertTrue(currentTodoPlan(messages).isEmpty())
83+ }
84+
85+ private fun message(id: String, vararg parts: Part) =
86+ MessageWithParts(AssistantMessage(id = id, sessionID = "s"), parts.toList())
87+
88+ private fun todoPart(id: String, inputJson: String): ToolPart =
89+ ToolPart(id = id, tool = "todowrite", state = ToolCompleted(input = element(inputJson)))
90+}