Clarify subagent coordination cards
This commit is contained in:
1 parent
cad0cbcfbe
commit
898e6b92d0
5 files changed
+151
-20
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -68,12 +68,14 @@ private val SUBJECTS: Map<String, Pair<String, Language?>> =
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
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"}}}"#
|
||||
))
|
||||
.is_empty()
|
||||
)),
|
||||
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!({
|
||||
|
||||
Reference in new issue
Block a user