feat(chat): support hierarchical nested subtasks in Todos via parent property (#1018) (#1028)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-31 22:09:15 +03:00
1 parent 3fd9ac767f
commit ceb4813218
9 files changed
+443 -17

No files matched your search

@@ -6,6 +6,7 @@ import io.mockk.unmockkAll
import kotlinx.serialization.json.JsonObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -700,4 +701,49 @@ class EventParserTest {
assertEquals("terminal", toolEvent.name)
assertEquals("sess-replay-2", toolEvent.sessionId)
}
@Test
fun testParseTodoUpdated_returnsTodoUpdatedEvent() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "todo.updated",
"session_id" to "sess-abc",
"payload" to
mapOf(
"revision" to 5,
"todos" to
listOf(
mapOf("id" to "1", "content" to "Parent task", "status" to "in_progress"),
mapOf(
"id" to "2",
"content" to "Sub task",
"status" to "pending",
"parent" to "1",
),
),
),
),
)
val event = EventParser.parse(response)
assertTrue(event is WsEvent.TodoUpdated)
val todoUpdated = event as WsEvent.TodoUpdated
assertEquals("sess-abc", todoUpdated.sessionId)
assertEquals(5, todoUpdated.revision)
assertEquals(2, todoUpdated.todos.size)
assertEquals("1", todoUpdated.todos[0].id)
assertEquals("Parent task", todoUpdated.todos[0].content)
assertEquals(null, todoUpdated.todos[0].parent)
assertFalse(todoUpdated.todos[0].isSubtask)
assertEquals("2", todoUpdated.todos[1].id)
assertEquals("Sub task", todoUpdated.todos[1].content)
assertEquals("1", todoUpdated.todos[1].parent)
assertTrue(todoUpdated.todos[1].isSubtask)
}
}
@@ -979,4 +979,88 @@ class ChatWsEventReducerTest {
assertEquals(1, result.state.compressionCount)
assertEquals(null, result.state.usedContextTokens)
}
@Test
fun testTodoUpdated_updatesStateTodos() {
val state = ChatUiState(currentSessionId = "session-1")
val todos =
listOf(
TodoItem(id = "1", content = "Main task", status = "in_progress"),
TodoItem(id = "2", content = "Subtask 1", status = "completed", parent = "1"),
TodoItem(id = "3", content = "Subtask 2", status = "pending", parent = "1"),
)
val event = WsEvent.TodoUpdated(todos = todos, revision = 1, sessionId = "session-1")
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertEquals(3, result.state.todos.size)
assertEquals("Main task", result.state.todos[0].content)
assertEquals(null, result.state.todos[0].parent)
assertEquals("1", result.state.todos[1].parent)
assertTrue(result.state.todos[1].isCompleted)
assertTrue(result.state.todos[1].isSubtask)
}
@Test
fun testTodoUpdated_differentSession_isIgnored() {
val state =
ChatUiState(
currentSessionId = "session-1",
todos = listOf(TodoItem(id = "orig", content = "Original", status = "pending")),
)
val event =
WsEvent.TodoUpdated(
todos = listOf(TodoItem(id = "new", content = "New", status = "in_progress")),
revision = 1,
sessionId = "session-other",
)
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
event = event,
currentSessionId = "session-1",
)
assertEquals(1, result.state.todos.size)
assertEquals("Original", result.state.todos[0].content)
}
@Test
fun testExtractTodosFromMap_preservesParentHierarchy() {
val payload =
mapOf(
"todos" to
listOf(
mapOf("id" to "t1", "content" to "Root", "status" to "in_progress"),
mapOf("id" to "t2", "content" to "Child", "status" to "pending", "parent" to "t1"),
),
)
val extracted = extractTodosFromMap(payload)
assertEquals(2, extracted?.size)
assertEquals("t1", extracted?.get(0)?.id)
assertEquals(null, extracted?.get(0)?.parent)
assertEquals("t2", extracted?.get(1)?.id)
assertEquals("t1", extracted?.get(1)?.parent)
}
@Test
fun testExtractTodosFromJson_preservesParentHierarchy() {
val json =
"""{"todos":[{"id":"a","content":"Task A","status":"done"},""" +
"""{"id":"b","content":"Task B","status":"pending","parent":"a"}]}"""
val extracted = extractTodosFromJson(json)
assertEquals(2, extracted?.size)
assertEquals("a", extracted?.get(0)?.id)
assertEquals(null, extracted?.get(0)?.parent)
assertEquals("b", extracted?.get(1)?.id)
assertEquals("a", extracted?.get(1)?.parent)
}
}
@@ -1,6 +1,9 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.components.buildTodoTree
import com.m57.hermescontrol.ui.chat.components.computeChipDisplay
import com.m57.hermescontrol.ui.chat.components.flattenTodoTree
import com.m57.hermescontrol.ui.chat.components.getAllDescendantTodos
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -142,4 +145,72 @@ class TaskProgressChipTest {
)
assertTrue(hydrateTodosFromMessages(messages).isEmpty())
}
@Test
fun `buildTodoTree constructs correct multi-level hierarchy`() {
val todos =
listOf(
TodoItem(id = "1", content = "Root 1", status = "in_progress"),
TodoItem(id = "1.1", content = "Child 1.1", status = "completed", parent = "1"),
TodoItem(id = "1.2", content = "Child 1.2", status = "pending", parent = "1"),
TodoItem(id = "1.2.1", content = "Grandchild 1.2.1", status = "pending", parent = "1.2"),
TodoItem(id = "2", content = "Root 2", status = "pending"),
)
val tree = buildTodoTree(todos)
assertEquals(2, tree.size)
assertEquals("1", tree[0].item.id)
assertEquals(0, tree[0].depth)
assertEquals(2, tree[0].children.size)
assertEquals("1.1", tree[0].children[0].item.id)
assertEquals(1, tree[0].children[0].depth)
assertEquals("1.2", tree[0].children[1].item.id)
assertEquals(1, tree[0].children[1].depth)
assertEquals(1, tree[0].children[1].children.size)
assertEquals(
"1.2.1",
tree[0]
.children[1]
.children[0]
.item.id,
)
assertEquals(2, tree[0].children[1].children[0].depth)
assertEquals("2", tree[1].item.id)
assertEquals(0, tree[1].depth)
}
@Test
fun `flattenTodoTree collapses and expands subtasks correctly`() {
val todos =
listOf(
TodoItem(id = "1", content = "Root 1", status = "in_progress"),
TodoItem(id = "1.1", content = "Child 1.1", status = "completed", parent = "1"),
TodoItem(id = "1.2", content = "Child 1.2", status = "pending", parent = "1"),
TodoItem(id = "2", content = "Root 2", status = "pending"),
)
val tree = buildTodoTree(todos)
// Expanded state
val expandedRows = flattenTodoTree(tree, collapsedParentIds = emptySet())
assertEquals(4, expandedRows.size)
assertEquals("1", expandedRows[0].todo.id)
assertTrue(expandedRows[0].hasChildren)
assertTrue(expandedRows[0].isExpanded)
assertEquals(1, expandedRows[0].completedSubtasksCount)
assertEquals(2, expandedRows[0].totalSubtasksCount)
assertEquals("1.1", expandedRows[1].todo.id)
assertEquals(1, expandedRows[1].depth)
assertEquals("1.2", expandedRows[2].todo.id)
assertEquals(1, expandedRows[2].depth)
assertEquals("2", expandedRows[3].todo.id)
assertEquals(0, expandedRows[3].depth)
// Collapsed state
val collapsedRows = flattenTodoTree(tree, collapsedParentIds = setOf("1"))
assertEquals(2, collapsedRows.size)
assertEquals("1", collapsedRows[0].todo.id)
assertTrue(collapsedRows[0].hasChildren)
assertFalse(collapsedRows[0].isExpanded)
assertEquals("2", collapsedRows[1].todo.id)
}
}