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:
xintaofei
2026-04-17 00:06:57 +08:00
parent 68142651b3
commit 488b0c2e53
2 changed files with 237 additions and 26 deletions

View File

@@ -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};

View File

@@ -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.