feat: add agent_type field and support Agent tool rename from CLI v2.1.69

- Parse agent_type from SubagentStart hook events and forward via claude:agent-update
- Support renamed Agent/Agent(type) tool alongside existing Task/Task(type) syntax
- Fall back to prompt field when description is absent in Agent tool input
- Display agentType (with subagentType fallback) in agent monitor badge
- Show agentId as tooltip on the type badge when available
This commit is contained in:
2026-03-11 11:21:43 -07:00
committed by Naomi Carrigan
parent 1f8825b0cb
commit 021269983d
6 changed files with 147 additions and 10 deletions
+117 -6
View File
@@ -913,13 +913,14 @@ fn handle_stderr(
tracing::debug!("Parsed SubagentStart hook: agent_id={}, parent={:?}",
agent_data.agent_id, agent_data.parent_tool_use_id);
// Emit an agent-update event with the agent_id
// Emit an agent-update event with the agent_id and agent_type
let _ = app.emit(
"claude:agent-update",
serde_json::json!({
"conversationId": conversation_id.clone(),
"toolUseId": agent_data.parent_tool_use_id,
"agentId": agent_data.agent_id,
"agentType": agent_data.agent_type,
}),
);
}
@@ -986,11 +987,12 @@ fn handle_stderr(
#[derive(Debug)]
struct SubagentStartData {
agent_id: String,
agent_type: Option<String>,
parent_tool_use_id: Option<String>,
}
fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
// Parse: [SubagentStart Hook] agent_id=agent-xxx, parent_tool_use_id=Some("toolu_xxx"), ...
// Parse: [SubagentStart Hook] agent_id=agent-xxx, agent_type=general-purpose, parent_tool_use_id=Some("toolu_xxx"), ...
// Extract agent_id
let agent_id = line
@@ -1001,6 +1003,15 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
.trim()
.to_string();
// Extract agent_type if present (added in CLI v2.1.69)
let agent_type = line
.split("agent_type=")
.nth(1)
.and_then(|s| {
let value = s.split(',').next()?.trim();
if value.is_empty() { None } else { Some(value.to_string()) }
});
// Extract parent_tool_use_id if present
let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") {
line.split("parent_tool_use_id=Some(\"")
@@ -1014,6 +1025,7 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
Some(SubagentStartData {
agent_id,
agent_type,
parent_tool_use_id,
})
}
@@ -1272,11 +1284,15 @@ fn process_json_line(
}
}
// Emit agent-start event for Task tool invocations
// Support both "Task" and "Task(agent_type)" syntax (CLI v2.1.33+)
if name == "Task" || name.starts_with("Task(") {
// Emit agent-start event for Task/Agent tool invocations
// Support "Task"/"Task(agent_type)" (CLI v2.1.33+) and
// "Agent"/"Agent(agent_type)" (CLI v2.1.69+ rename)
if name == "Task" || name.starts_with("Task(")
|| name == "Agent" || name.starts_with("Agent(")
{
let description = input
.get("description")
.or_else(|| input.get("prompt"))
.and_then(|v| v.as_str())
.unwrap_or("Subagent")
.to_string();
@@ -1943,7 +1959,9 @@ fn get_tool_state(tool_name: &str) -> CharacterState {
CharacterState::Coding
} else if tool_name.starts_with("mcp__") {
CharacterState::Mcp
} else if tool_name == "Task" || tool_name.starts_with("Task(") {
} else if tool_name == "Task" || tool_name.starts_with("Task(")
|| tool_name == "Agent" || tool_name.starts_with("Agent(")
{
CharacterState::Thinking
} else {
CharacterState::Typing
@@ -2067,6 +2085,19 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
}
}
"CronList" => "Listing scheduled tasks...".to_string(),
name if name == "Agent" || name.starts_with("Agent(") => {
let task = input
.get("description")
.or_else(|| input.get("prompt"))
.and_then(|v| v.as_str());
let agent_type = input.get("subagent_type").and_then(|v| v.as_str());
match (task, agent_type) {
(Some(t), Some(a)) => format!("Launching {} agent: {}", a, t),
(Some(t), None) => format!("Launching agent: {}", t),
(None, Some(a)) => format!("Launching {} agent...", a),
(None, None) => "Launching agent...".to_string(),
}
}
_ => format!("Using tool: {}", name),
}
}
@@ -2160,6 +2191,24 @@ mod tests {
));
}
#[test]
fn test_get_tool_state_agent() {
// CLI v2.1.69+ renamed Task to Agent
assert!(matches!(get_tool_state("Agent"), CharacterState::Thinking));
assert!(matches!(
get_tool_state("Agent(Explore)"),
CharacterState::Thinking
));
assert!(matches!(
get_tool_state("Agent(Plan)"),
CharacterState::Thinking
));
assert!(matches!(
get_tool_state("Agent(general-purpose)"),
CharacterState::Thinking
));
}
#[test]
fn test_get_tool_state_unknown() {
assert!(matches!(
@@ -2289,6 +2338,41 @@ mod tests {
assert_eq!(desc, "Listing scheduled tasks...");
}
#[test]
fn test_format_tool_description_agent_with_type_and_description() {
let input = serde_json::json!({"subagent_type": "general-purpose", "description": "Fetch user info"});
let desc = format_tool_description("Agent", &input);
assert_eq!(desc, "Launching general-purpose agent: Fetch user info");
}
#[test]
fn test_format_tool_description_agent_with_prompt() {
let input = serde_json::json!({"subagent_type": "Explore", "prompt": "Look at the repo"});
let desc = format_tool_description("Agent", &input);
assert_eq!(desc, "Launching Explore agent: Look at the repo");
}
#[test]
fn test_format_tool_description_agent_no_description() {
let input = serde_json::json!({"subagent_type": "Plan"});
let desc = format_tool_description("Agent", &input);
assert_eq!(desc, "Launching Plan agent...");
}
#[test]
fn test_format_tool_description_agent_no_fields() {
let input = serde_json::json!({});
let desc = format_tool_description("Agent", &input);
assert_eq!(desc, "Launching agent...");
}
#[test]
fn test_format_tool_description_agent_with_parenthesized_type() {
let input = serde_json::json!({"description": "Check files"});
let desc = format_tool_description("Agent(Explore)", &input);
assert_eq!(desc, "Launching agent: Check files");
}
#[test]
fn test_format_tool_description_memory_read() {
let input =
@@ -2415,6 +2499,7 @@ mod tests {
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.agent_id, "agent-abc123");
assert_eq!(data.agent_type, None);
assert_eq!(data.parent_tool_use_id, Some("toolu_01XYZ789".to_string()));
}
@@ -2426,6 +2511,7 @@ mod tests {
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.agent_id, "agent-xyz789");
assert_eq!(data.agent_type, None);
assert_eq!(data.parent_tool_use_id, None);
}
@@ -2445,9 +2531,34 @@ mod tests {
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.agent_id, "agent-test");
assert_eq!(data.agent_type, None);
assert_eq!(data.parent_tool_use_id, Some("toolu_test".to_string()));
}
#[test]
fn test_parse_subagent_start_hook_with_agent_type() {
let line = r#"[SubagentStart Hook] agent_id=agent-abc123, agent_type=general-purpose, parent_tool_use_id=Some("toolu_01XYZ789"), session_id=123"#;
let result = parse_subagent_start_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.agent_id, "agent-abc123");
assert_eq!(data.agent_type, Some("general-purpose".to_string()));
assert_eq!(data.parent_tool_use_id, Some("toolu_01XYZ789".to_string()));
}
#[test]
fn test_parse_subagent_start_hook_with_explore_type() {
let line = r#"[SubagentStart Hook] agent_id=agent-xyz789, agent_type=Explore, parent_tool_use_id=None, session_id=456"#;
let result = parse_subagent_start_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.agent_id, "agent-xyz789");
assert_eq!(data.agent_type, Some("Explore".to_string()));
assert_eq!(data.parent_tool_use_id, None);
}
// SubagentStop hook parsing tests
#[test]
fn test_parse_subagent_stop_hook_with_parent() {
+2 -1
View File
@@ -282,8 +282,9 @@
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
agent.status
)}"
title={agent.agentId ? `ID: ${agent.agentId}` : undefined}
>
{getSubagentTypeLabel(agent.subagentType)}
{getSubagentTypeLabel(agent.agentType ?? agent.subagentType)}
</span>
</div>
<span
+22
View File
@@ -121,6 +121,28 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].agentId).toBeUndefined();
});
it("updates agentType when provided alongside agentId", () => {
const agent = createMockAgent({ agentId: undefined });
agentStore.addAgent(conversationId, agent);
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123", "general-purpose");
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].agentId).toBe("agent-abc123");
expect(agents[0].agentType).toBe("general-purpose");
});
it("does not set agentType when not provided", () => {
const agent = createMockAgent({ agentId: undefined });
agentStore.addAgent(conversationId, agent);
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123");
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].agentId).toBe("agent-abc123");
expect(agents[0].agentType).toBeUndefined();
});
});
describe("endAgent", () => {
+2 -1
View File
@@ -24,7 +24,7 @@ function createAgentStore() {
});
},
updateAgentId(conversationId: string, toolUseId: string, agentId: string) {
updateAgentId(conversationId: string, toolUseId: string, agentId: string, agentType?: string) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
@@ -36,6 +36,7 @@ function createAgentStore() {
updated[agentIndex] = {
...updated[agentIndex],
agentId,
...(agentType !== undefined ? { agentType } : {}),
};
return {
+3 -2
View File
@@ -538,9 +538,10 @@ export async function initializeTauriListeners() {
conversationId: string;
toolUseId: string;
agentId: string;
agentType?: string;
}>("claude:agent-update", (event) => {
const { conversationId, toolUseId, agentId } = event.payload;
agentStore.updateAgentId(conversationId, toolUseId, agentId);
const { conversationId, toolUseId, agentId, agentType } = event.payload;
agentStore.updateAgentId(conversationId, toolUseId, agentId, agentType);
});
unlisteners.push(agentUpdateUnlisten);
+1
View File
@@ -3,6 +3,7 @@ export type AgentStatus = "running" | "completed" | "errored";
export interface AgentInfo {
toolUseId: string;
agentId?: string;
agentType?: string;
description: string;
subagentType: string;
startedAt: number;