From 898e6b92d0fa544e720df83d393a152ee3bdcd2c Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sun, 13 Sep 2026 02:48:21 -0400 Subject: [PATCH] Clarify subagent coordination cards --- SUBAGENTS.md | 7 ++ .../kotlin/com/example/aiapp/ToolInput.kt | 14 ++- .../main/kotlin/com/example/aiapp/ToolRows.kt | 24 +++- .../kotlin/com/example/aiapp/ToolInputTest.kt | 14 +++ server/src/session/codex/translate.rs | 114 +++++++++++++++--- 5 files changed, 152 insertions(+), 21 deletions(-) diff --git a/SUBAGENTS.md b/SUBAGENTS.md index 6c31562..256d42e 100644 --- a/SUBAGENTS.md +++ b/SUBAGENTS.md @@ -33,6 +33,13 @@ does not repeat the completed item's `delivery` field, so the translator remembers that field from `item/started` and suppresses those deltas. Letting one into the recipient's provisional assistant row makes its next completed message replace the combined row, visibly erasing text that Codex still has. +The parent draws the initial `spawnAgent` as its ordinary `Task` card and +closes it when the matching `subAgentActivity.started` arrives. The remaining +collaboration calls remain visible as coordination -- waiting, messaging, +listing and lifecycle controls -- rather than being mistaken for generic task +output. Null optional fields and a bare `completed` status carry no information +and are omitted; their useful result is the child transcript, status or peer +message beside them. ## Storage diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt index 090aca5..fe23274 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt @@ -68,12 +68,14 @@ private val SUBJECTS: Map> = private val DESCRIPTIONS = listOf("description", "prompt") fun parseToolInput(tool: String, input: String): ToolInput { + if (input.trim() == "null") return ToolInput(null, null, null, null, emptyList()) val json = try { JSONObject(input) } catch (_: org.json.JSONException) { // Not an object: older transcripts and some tools send a bare string. It is still the - // input, so it is still shown. + // input, so it is still shown. JSON null is the one exception: it means the call had + // no input, and drawing the word makes an absent value look like an instruction. return ToolInput( null, null, @@ -85,17 +87,18 @@ fun parseToolInput(tool: String, input: String): ToolInput { val (subjectKey, language) = SUBJECTS[tool] ?: (null to null) val subject = subjectKey - ?.let { json.optString(it) } + ?.let { json.text(it) } ?.takeIf { it.isNotBlank() } ?.let { if (tool == "Bash") renderedBashScript(it) ?: it else it } val description = DESCRIPTIONS.firstNotNullOfOrNull { - json.optString(it).takeIf { v -> v.isNotBlank() } + json.text(it)?.takeIf { value -> value.isNotBlank() } } - val timeout = json.optString("timeout").takeIf { it.isNotBlank() }?.let { formatMillisText(it) } + val timeout = json.text("timeout")?.takeIf { it.isNotBlank() }?.let { formatMillisText(it) } val rest = json .keys() .asSequence() + .filterNot(json::isNull) .filter { it != subjectKey || subject == null } .filter { it !in DESCRIPTIONS || description == null } .filter { it != "timeout" || timeout == null } @@ -105,6 +108,9 @@ fun parseToolInput(tool: String, input: String): ToolInput { return ToolInput(subject, language, description, timeout, rest) } +private fun JSONObject.text(key: String): String? = + if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } + /** * Removes Codex's rendered Bash argv from old transcript rows. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index 6fc28c4..32e7855 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -295,12 +295,14 @@ fun ToolCard( shape: Shape = CardDefaults.shape, ) { val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) } + val name = toolDisplayName(tool.tool) + val output = toolDisplayOutput(tool.tool, tool.output) val deciding = tool.asks.any { it.answers.isEmpty() } val open = expanded || deciding Card(Modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) { Column(Modifier.padding(GROUP_INSET_LARGE)) { Row(verticalAlignment = Alignment.CenterVertically) { - Text(tool.tool, style = MaterialTheme.typography.titleSmall) + Text(name, style = MaterialTheme.typography.titleSmall) if (open) { Spacer(Modifier.weight(1f)) parsed.timeout?.let { @@ -355,7 +357,7 @@ fun ToolCard( if (tool.tool != ASK_USER_QUESTION) { ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp)) } - if (tool.output.isNotEmpty()) { + if (output.isNotEmpty()) { Spacer(Modifier.height(8.dp)) Text("Output", style = MaterialTheme.typography.labelSmall) // What the tool printed, on the surface everything verbatim gets and in the @@ -367,7 +369,7 @@ fun ToolCard( // often the whole of what a diff or a test run is saying. Remembered against // the text, so a card that is open through a scroll parses once. val palette = remember { ansiPalette() } - val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) } + val styled = remember(output, palette) { ansiStyled(output, palette) } RawBlock(Modifier.padding(top = 2.dp)) { Text( styled, @@ -391,6 +393,22 @@ fun ToolCard( } } +private val collaborationToolNames = + mapOf( + "Task" to "Spawn agent", + "TaskOutput" to "Wait for agents", + "SendMessage" to "Message agent", + "CloseAgent" to "Close agent", + "InterruptAgent" to "Interrupt agent", + "ListAgents" to "List agents", + "ResumeAgent" to "Resume agent", + ) + +internal fun toolDisplayName(tool: String): String = collaborationToolNames[tool] ?: tool + +internal fun toolDisplayOutput(tool: String, output: String): String = + if (tool in collaborationToolNames && output == "completed") "" else output + /** * The permission ask on the call it is about. * diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/ToolInputTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/ToolInputTest.kt index 9713335..2d713bd 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/ToolInputTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/ToolInputTest.kt @@ -3,6 +3,7 @@ package com.example.aiapp import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue class ToolInputTest { @Test @@ -26,4 +27,17 @@ class ToolInputTest { assertNull(renderedBashScript("/usr/bin/bash -lc echo hello")) assertNull(renderedBashScript("/usr/bin/fish -lc 'echo hello'")) } + + @Test + fun `missing optional input is not displayed as null`() { + assertTrue(parseToolInput("TaskOutput", "null").rest.isEmpty()) + } + + @Test + fun `collaboration calls say what they do and omit empty completion`() { + assertEquals("Spawn agent", toolDisplayName("Task")) + assertEquals("Wait for agents", toolDisplayName("TaskOutput")) + assertEquals("", toolDisplayOutput("TaskOutput", "completed")) + assertEquals("failed", toolDisplayOutput("TaskOutput", "failed")) + } } diff --git a/server/src/session/codex/translate.rs b/server/src/session/codex/translate.rs index 929d6e6..edf5a0a 100644 --- a/server/src/session/codex/translate.rs +++ b/server/src/session/codex/translate.rs @@ -348,7 +348,15 @@ impl Translator { if let Some(subagents) = &self.subagents { subagents.reopen(id); } - Vec::new() + item.get("id") + .and_then(Value::as_str) + .map(|call| { + vec![Event::ToolEnd { + id: call.to_string(), + output: String::new(), + }] + }) + .unwrap_or_default() } Some("interacted") => { self.ensure_child(id, &title, None); @@ -591,14 +599,14 @@ fn tool(item: &Value) -> Option<(String, String, Value)> { None => "Agent", }; let mut input = json!({}); - if let Some(prompt) = item.get("prompt") { - input["prompt"] = prompt.clone(); + if let Some(prompt) = item.get("prompt").and_then(Value::as_str) { + input["prompt"] = Value::String(prompt.to_string()); } - if let Some(model) = item.get("model") { - input["model"] = model.clone(); + if let Some(model) = item.get("model").and_then(Value::as_str) { + input["model"] = Value::String(model.to_string()); } - if let Some(effort) = item.get("reasoningEffort") { - input["reasoningEffort"] = effort.clone(); + if let Some(effort) = item.get("reasoningEffort").and_then(Value::as_str) { + input["reasoningEffort"] = Value::String(effort.to_string()); } (name.to_string(), input) } @@ -766,6 +774,11 @@ fn tool_output(item: &Value) -> String { { return String::new(); } + Some("collabAgentToolCall") + if item.get("status").and_then(Value::as_str) == Some("completed") => + { + return String::new(); + } Some("dynamicToolCall") => { return content_text(item.get("contentItems"), "inputText"); } @@ -1131,15 +1144,17 @@ mod tests { vec![Event::ToolStart { id: "spawn-1".to_string(), tool: "Task".to_string(), - input: json!({"prompt": "audit the history", "model": null, "reasoningEffort": null}) + input: json!({"prompt": "audit the history"}) }] ); - assert!( - translator - .translate(&line( - r#"{"method":"item/completed","params":{"threadId":"parent-thread","turnId":"turn-1","item":{"id":"spawn-1","type":"subAgentActivity","kind":"started","agentThreadId":"child-thread","agentPath":"/root/history_boundaries"}}}"# - )) - .is_empty() + assert_eq!( + translator.translate(&line( + r#"{"method":"item/completed","params":{"threadId":"parent-thread","turnId":"turn-1","item":{"id":"spawn-1","type":"subAgentActivity","kind":"started","agentThreadId":"child-thread","agentPath":"/root/history_boundaries"}}}"# + )), + vec![Event::ToolEnd { + id: "spawn-1".to_string(), + output: String::new() + }] ); let rows = subagents.list(true); assert_eq!(rows.len(), 1); @@ -1203,6 +1218,77 @@ mod tests { ); } + #[test] + fn codex_collaboration_coordination_has_clean_parent_tool_cards() { + let mut translator = Translator::default(); + for (tool, name) in [ + ("wait", "TaskOutput"), + ("sendInput", "SendMessage"), + ("sendMessage", "SendMessage"), + ("followupTask", "SendMessage"), + ("closeAgent", "CloseAgent"), + ("interruptAgent", "InterruptAgent"), + ("listAgents", "ListAgents"), + ("resumeAgent", "ResumeAgent"), + ] { + let started = json!({ + "method": "item/started", + "params": { + "threadId": "parent-thread", + "turnId": "turn-1", + "item": { + "id": format!("{tool}-1"), + "type": "collabAgentToolCall", + "tool": tool, + "status": "inProgress", + "senderThreadId": "parent-thread", + "receiverThreadIds": [], + "agentsStates": {}, + "prompt": null, + "model": null, + "reasoningEffort": null + } + } + }); + let completed = json!({ + "method": "item/completed", + "params": { + "threadId": "parent-thread", + "turnId": "turn-1", + "item": { + "id": format!("{tool}-1"), + "type": "collabAgentToolCall", + "tool": tool, + "status": "completed", + "senderThreadId": "parent-thread", + "receiverThreadIds": [], + "agentsStates": {}, + "prompt": null, + "model": null, + "reasoningEffort": null + } + } + }); + assert_eq!( + translator.translate(&started), + vec![Event::ToolStart { + id: format!("{tool}-1"), + tool: name.to_string(), + input: json!({}) + }], + "{tool} start" + ); + assert_eq!( + translator.translate(&completed), + vec![Event::ToolEnd { + id: format!("{tool}-1"), + output: String::new() + }], + "{tool} completion" + ); + } + } + #[test] fn delivered_agent_messages_are_peer_messages_not_the_parents_reply() { let event = final_item(&json!({