feat(parser): render OpenCode task as Agent cards with nested tool calls
Transform OpenCode "task" tool calls with subagent_type into unified Agent card display: rewrite as "Agent" tool with subagent_type, prompt, description, and model; compute duration from time fields; query sub-agent session parts from SQLite for nested tool call lists; extract <task_result> content to strip preamble noise from agent output; guard Streamdown code plugin against unsupported Shiki language identifiers (e.g. "##", "function") in fenced code blocks from tool output.
This commit is contained in:
@@ -354,47 +354,145 @@ impl OpenCodeParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"tool" => {
|
"tool" => {
|
||||||
let tool_name = value
|
let raw_tool_name = value
|
||||||
.get("tool")
|
.get("tool")
|
||||||
.and_then(|t| t.as_str())
|
.and_then(|t| t.as_str())
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown");
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let call_id = value
|
let call_id = value
|
||||||
.get("callID")
|
.get("callID")
|
||||||
.and_then(|c| c.as_str())
|
.and_then(|c| c.as_str())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
let status = value
|
let state = value.get("state");
|
||||||
.get("state")
|
let status = state
|
||||||
.and_then(|s| s.get("status"))
|
.and_then(|s| s.get("status"))
|
||||||
.and_then(|s| s.as_str())
|
.and_then(|s| s.as_str())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
let input_preview = value
|
let state_input = state.and_then(|s| s.get("input"));
|
||||||
.get("state")
|
let is_agent_task = raw_tool_name == "task"
|
||||||
.and_then(|s| s.get("input"))
|
&& state_input
|
||||||
.and_then(|v| value_to_preview(Some(v)));
|
.and_then(|i| i.get("subagent_type"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.is_some();
|
||||||
|
|
||||||
blocks.push(ContentBlock::ToolUse {
|
if is_agent_task {
|
||||||
tool_use_id: call_id.clone(),
|
// Transform task tool into Agent card
|
||||||
tool_name,
|
let subagent_type = state_input
|
||||||
input_preview,
|
.and_then(|i| i.get("subagent_type"))
|
||||||
});
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("agent");
|
||||||
|
let prompt = state_input
|
||||||
|
.and_then(|i| i.get("prompt"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let description = state
|
||||||
|
.and_then(|s| s.get("title"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.or_else(|| {
|
||||||
|
state_input
|
||||||
|
.and_then(|i| i.get("description"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
})
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
let output_preview = value
|
let metadata = state.and_then(|s| s.get("metadata"));
|
||||||
.get("state")
|
let model_id = metadata
|
||||||
.and_then(|s| s.get("output"))
|
.and_then(|m| m.get("model"))
|
||||||
.and_then(|v| value_to_preview(Some(v)));
|
.and_then(|m| m.get("modelID"))
|
||||||
|
.and_then(|v| v.as_str());
|
||||||
|
let session_id = metadata
|
||||||
|
.and_then(|m| m.get("sessionId"))
|
||||||
|
.and_then(|v| v.as_str());
|
||||||
|
|
||||||
let has_error_field = value.get("state").and_then(|s| s.get("error")).is_some();
|
let mut agent_input = serde_json::json!({
|
||||||
|
"subagent_type": subagent_type,
|
||||||
|
"description": description,
|
||||||
|
"prompt": prompt,
|
||||||
|
});
|
||||||
|
if let Some(model) = model_id {
|
||||||
|
agent_input["model"] = serde_json::Value::String(model.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
blocks.push(ContentBlock::ToolResult {
|
blocks.push(ContentBlock::ToolUse {
|
||||||
tool_use_id: call_id,
|
tool_use_id: call_id.clone(),
|
||||||
output_preview,
|
tool_name: "Agent".to_string(),
|
||||||
is_error: is_error_status(status) || has_error_field,
|
input_preview: Some(agent_input.to_string()),
|
||||||
agent_stats: None,
|
});
|
||||||
});
|
|
||||||
|
let output_preview = state
|
||||||
|
.and_then(|s| s.get("output"))
|
||||||
|
.and_then(|v| value_to_preview(Some(v)))
|
||||||
|
.map(|s| extract_task_result_content(&s));
|
||||||
|
|
||||||
|
// Compute duration from time fields
|
||||||
|
let time = state.and_then(|s| s.get("time"));
|
||||||
|
let start_ms = time.and_then(|t| t.get("start")).and_then(|v| v.as_i64());
|
||||||
|
let end_ms = time.and_then(|t| t.get("end")).and_then(|v| v.as_i64());
|
||||||
|
let duration_ms = match (start_ms, end_ms) {
|
||||||
|
(Some(s), Some(e)) if e > s => Some((e - s) as u64),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load sub-agent tool calls from the sub-agent session
|
||||||
|
let tool_calls = if let Some(sid) = session_id {
|
||||||
|
load_subagent_tool_calls(conn, sid).await
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let tool_count = tool_calls.len() as u32;
|
||||||
|
let agent_stats = Some(AgentExecutionStats {
|
||||||
|
agent_type: Some(subagent_type.to_string()),
|
||||||
|
status: Some(status.to_string()),
|
||||||
|
total_duration_ms: duration_ms,
|
||||||
|
total_tokens: None,
|
||||||
|
total_tool_use_count: if tool_count > 0 {
|
||||||
|
Some(tool_count)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
read_count: None,
|
||||||
|
search_count: None,
|
||||||
|
bash_count: None,
|
||||||
|
edit_file_count: None,
|
||||||
|
lines_added: None,
|
||||||
|
lines_removed: None,
|
||||||
|
other_tool_count: None,
|
||||||
|
tool_calls,
|
||||||
|
});
|
||||||
|
|
||||||
|
let has_error_field = state.and_then(|s| s.get("error")).is_some();
|
||||||
|
blocks.push(ContentBlock::ToolResult {
|
||||||
|
tool_use_id: call_id,
|
||||||
|
output_preview,
|
||||||
|
is_error: is_error_status(status) || has_error_field,
|
||||||
|
agent_stats,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let input_preview = state_input
|
||||||
|
.and_then(|v| value_to_preview(Some(v)));
|
||||||
|
|
||||||
|
blocks.push(ContentBlock::ToolUse {
|
||||||
|
tool_use_id: call_id.clone(),
|
||||||
|
tool_name: raw_tool_name.to_string(),
|
||||||
|
input_preview,
|
||||||
|
});
|
||||||
|
|
||||||
|
let output_preview = state
|
||||||
|
.and_then(|s| s.get("output"))
|
||||||
|
.and_then(|v| value_to_preview(Some(v)));
|
||||||
|
|
||||||
|
let has_error_field = state.and_then(|s| s.get("error")).is_some();
|
||||||
|
|
||||||
|
blocks.push(ContentBlock::ToolResult {
|
||||||
|
tool_use_id: call_id,
|
||||||
|
output_preview,
|
||||||
|
is_error: is_error_status(status) || has_error_field,
|
||||||
|
agent_stats: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"file" => {
|
"file" => {
|
||||||
if let Some(image_block) = extract_opencode_file_image(&value) {
|
if let Some(image_block) = extract_opencode_file_image(&value) {
|
||||||
@@ -704,6 +802,104 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
|
|||||||
turns
|
turns
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract the content inside `<task_result>…</task_result>` tags from OpenCode
|
||||||
|
/// task output, stripping the `task_id:` preamble and the wrapper tags.
|
||||||
|
/// Returns the original string unchanged if no tags are found.
|
||||||
|
fn extract_task_result_content(raw: &str) -> String {
|
||||||
|
if let Some(start) = raw.find("<task_result>") {
|
||||||
|
let content_start = start + "<task_result>".len();
|
||||||
|
let content_end = raw[content_start..]
|
||||||
|
.find("</task_result>")
|
||||||
|
.map(|i| content_start + i)
|
||||||
|
.unwrap_or(raw.len());
|
||||||
|
let extracted = raw[content_start..content_end].trim();
|
||||||
|
if !extracted.is_empty() {
|
||||||
|
return extracted.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load tool calls from a sub-agent's session in the OpenCode SQLite database.
|
||||||
|
///
|
||||||
|
/// Queries all messages and their parts in the given session, extracts tool-type
|
||||||
|
/// parts, and returns a compact list of `AgentToolCall` records for display
|
||||||
|
/// inside the parent Agent card.
|
||||||
|
async fn load_subagent_tool_calls(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
session_id: &str,
|
||||||
|
) -> Vec<AgentToolCall> {
|
||||||
|
let rows = match conn
|
||||||
|
.query_all(Statement::from_sql_and_values(
|
||||||
|
DbBackend::Sqlite,
|
||||||
|
r#"
|
||||||
|
SELECT p.data
|
||||||
|
FROM part p
|
||||||
|
INNER JOIN message m ON m.id = p.message_id
|
||||||
|
WHERE m.session_id = ?
|
||||||
|
AND json_extract(p.data, '$.type') = 'tool'
|
||||||
|
ORDER BY p.time_created ASC, p.id ASC
|
||||||
|
"#,
|
||||||
|
[session_id.into()],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tool_calls = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let data_raw: String = match row.try_get("", "data") {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let value: serde_json::Value = match serde_json::from_str(&data_raw) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let tool_name = value
|
||||||
|
.get("tool")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Skip nested task calls to avoid recursion
|
||||||
|
let is_nested_task = tool_name == "task"
|
||||||
|
&& value
|
||||||
|
.get("state")
|
||||||
|
.and_then(|s| s.get("input"))
|
||||||
|
.and_then(|i| i.get("subagent_type"))
|
||||||
|
.is_some();
|
||||||
|
if is_nested_task {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = value.get("state");
|
||||||
|
let input_preview = state
|
||||||
|
.and_then(|s| s.get("input"))
|
||||||
|
.and_then(|v| value_to_preview(Some(v)));
|
||||||
|
let output_preview = state
|
||||||
|
.and_then(|s| s.get("output"))
|
||||||
|
.and_then(|v| value_to_preview(Some(v)));
|
||||||
|
let status = state
|
||||||
|
.and_then(|s| s.get("status"))
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let has_error_field = state.and_then(|s| s.get("error")).is_some();
|
||||||
|
|
||||||
|
tool_calls.push(AgentToolCall {
|
||||||
|
tool_name,
|
||||||
|
input_preview,
|
||||||
|
output_preview,
|
||||||
|
is_error: is_error_status(status) || has_error_field,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tool_calls
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{extract_opencode_file_image, resolve_xdg_data_home};
|
use super::{extract_opencode_file_image, resolve_xdg_data_home};
|
||||||
|
|||||||
@@ -327,7 +327,22 @@ export const MessageBranchPage = ({
|
|||||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>
|
export type MessageResponseProps = ComponentProps<typeof Streamdown>
|
||||||
|
|
||||||
const math = createMathPlugin({ singleDollarTextMath: true })
|
const math = createMathPlugin({ singleDollarTextMath: true })
|
||||||
const streamdownPlugins = { cjk, code, math, mermaid }
|
|
||||||
|
// Wrap the code plugin to guard against unsupported language identifiers
|
||||||
|
// (e.g. "##", "function") that appear in fenced code blocks from tool output.
|
||||||
|
// Without this, Shiki's createHighlighter tries to load unknown grammars and
|
||||||
|
// produces noisy console errors.
|
||||||
|
const safeCode: typeof code = {
|
||||||
|
...code,
|
||||||
|
highlight(options, callback) {
|
||||||
|
const lang = code.supportsLanguage(options.language)
|
||||||
|
? options.language
|
||||||
|
: ("text" as typeof options.language)
|
||||||
|
return code.highlight({ ...options, language: lang }, callback)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamdownPlugins = { cjk, code: safeCode, math, mermaid }
|
||||||
|
|
||||||
// remark-math only supports `$` delimiters. Convert LaTeX-style
|
// remark-math only supports `$` delimiters. Convert LaTeX-style
|
||||||
// `\[...\]` / `\(...\)` to `$$...$$` / `$...$` so they are recognized.
|
// `\[...\]` / `\(...\)` to `$$...$$` / `$...$` so they are recognized.
|
||||||
|
|||||||
Reference in New Issue
Block a user