From 1fe39bbbc1fdc6c0692d0e1db3883c4c88090055 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Feb 2026 18:26:12 -0800 Subject: [PATCH] feat: surface last_assistant_message from SubagentStop hook payloads Extracts last_assistant_message from SubagentStop hook events and surfaces it in the agent monitor panel as a summary snippet below each completed agent card. Closes #156 --- src-tauri/src/types.rs | 2 + src-tauri/src/wsl_bridge.rs | 118 ++++++++++++++++++-- src/lib/components/AgentMonitorPanel.svelte | 10 ++ src/lib/stores/agents.test.ts | 26 +++++ src/lib/stores/agents.ts | 9 +- src/lib/tauri.ts | 11 +- src/lib/types/agents.ts | 2 + 7 files changed, 164 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index c89bb63..8fa1a9e 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -305,6 +305,8 @@ pub struct AgentEndEvent { pub duration_ms: Option, #[serde(skip_serializing_if = "Option::is_none")] pub num_turns: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_assistant_message: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 083ac3c..1b14dd7 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -755,6 +755,7 @@ fn handle_stderr( conversation_id: conversation_id.clone(), duration_ms: None, num_turns: None, + last_assistant_message: stop_data.last_assistant_message, }, ); } @@ -828,24 +829,49 @@ fn parse_subagent_start_hook(line: &str) -> Option { #[derive(Debug)] struct SubagentStopData { parent_tool_use_id: Option, + last_assistant_message: Option, +} + +/// Extracts the content of a Rust Debug-formatted `Some("...")` field from a hook line. +/// Handles escaped characters (e.g. `\"` → `"`, `\\` → `\`, `\n` → newline). +/// Returns `None` if the field is absent or formatted as `None`. +fn extract_debug_string_value(line: &str, key: &str) -> Option { + let prefix = format!("{}=Some(\"", key); + let start_idx = line.find(&prefix)? + prefix.len(); + let rest = &line[start_idx..]; + + let mut result = String::new(); + let mut chars = rest.chars(); + loop { + match chars.next() { + Some('"') => return Some(result), + Some('\\') => match chars.next() { + Some('n') => result.push('\n'), + Some('t') => result.push('\t'), + Some('"') => result.push('"'), + Some('\\') => result.push('\\'), + Some(c) => { + result.push('\\'); + result.push(c); + } + None => break, + }, + Some(c) => result.push(c), + None => break, + } + } + None } fn parse_subagent_stop_hook(line: &str) -> Option { - // Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), ... + // Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), last_assistant_message=Some("..."), ... - // 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(\"") - .nth(1)? - .split('"') - .next() - .map(|s| s.to_string()) - } else { - None - }; + let parent_tool_use_id = extract_debug_string_value(line, "parent_tool_use_id"); + let last_assistant_message = extract_debug_string_value(line, "last_assistant_message"); Some(SubagentStopData { parent_tool_use_id, + last_assistant_message, }) } @@ -1169,6 +1195,7 @@ fn process_json_line( conversation_id: conversation_id.clone(), duration_ms: None, num_turns: None, + last_assistant_message: None, }, ); } @@ -1604,6 +1631,7 @@ fn process_json_line( conversation_id: conversation_id.clone(), duration_ms: None, num_turns: None, + last_assistant_message: None, }, ); } @@ -2155,5 +2183,73 @@ mod tests { assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.parent_tool_use_id, None); + assert_eq!(data.last_assistant_message, None); + } + + #[test] + fn test_parse_subagent_stop_hook_with_last_message() { + let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=Some("Task completed successfully."), session_id=123"#; + let result = parse_subagent_stop_hook(line); + + assert!(result.is_some()); + let data = result.unwrap(); + assert_eq!(data.parent_tool_use_id, Some("toolu_01ABC123".to_string())); + assert_eq!( + data.last_assistant_message, + Some("Task completed successfully.".to_string()) + ); + } + + #[test] + fn test_parse_subagent_stop_hook_with_last_message_none() { + let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=None, session_id=123"#; + let result = parse_subagent_stop_hook(line); + + assert!(result.is_some()); + let data = result.unwrap(); + assert_eq!(data.last_assistant_message, None); + } + + #[test] + fn test_extract_debug_string_value_simple() { + let line = r#"key=Some("hello world")"#; + assert_eq!( + extract_debug_string_value(line, "key"), + Some("hello world".to_string()) + ); + } + + #[test] + fn test_extract_debug_string_value_with_escaped_quotes() { + let line = r#"key=Some("say \"hi\" there")"#; + assert_eq!( + extract_debug_string_value(line, "key"), + Some(r#"say "hi" there"#.to_string()) + ); + } + + #[test] + fn test_extract_debug_string_value_none_variant() { + let line = "key=None"; + assert_eq!(extract_debug_string_value(line, "key"), None); + } + + #[test] + fn test_extract_debug_string_value_missing_key() { + let line = "other=Some(\"value\")"; + assert_eq!(extract_debug_string_value(line, "key"), None); + } + + #[test] + fn test_parse_subagent_stop_hook_with_commas_in_message() { + let line = r#"[SubagentStop Hook] parent_tool_use_id=Some("toolu_01"), last_assistant_message=Some("Found 3 files, all passing.")"#; + let result = parse_subagent_stop_hook(line); + + assert!(result.is_some()); + let data = result.unwrap(); + assert_eq!( + data.last_assistant_message, + Some("Found 3 files, all passing.".to_string()) + ); } } diff --git a/src/lib/components/AgentMonitorPanel.svelte b/src/lib/components/AgentMonitorPanel.svelte index ef7770c..6efc412 100644 --- a/src/lib/components/AgentMonitorPanel.svelte +++ b/src/lib/components/AgentMonitorPanel.svelte @@ -318,6 +318,16 @@ Errored / Killed {/if} + + + {#if agent.lastAssistantMessage} +

+ {agent.lastAssistantMessage} +

+ {/if} {/each} {/if} diff --git a/src/lib/stores/agents.test.ts b/src/lib/stores/agents.test.ts index 1051186..5806c9e 100644 --- a/src/lib/stores/agents.test.ts +++ b/src/lib/stores/agents.test.ts @@ -177,6 +177,32 @@ describe("agents store", () => { const agents = get(getAgentsForConversation(conversationId)); expect(agents[0].status).toBe("running"); // Status unchanged }); + + it("stores lastAssistantMessage when provided", () => { + const agent = createMockAgent({ status: "running" }); + agentStore.addAgent(conversationId, agent); + + agentStore.endAgent( + conversationId, + agent.toolUseId, + Date.now(), + false, + "Task completed successfully." + ); + + const agents = get(getAgentsForConversation(conversationId)); + expect(agents[0].lastAssistantMessage).toBe("Task completed successfully."); + }); + + it("leaves lastAssistantMessage undefined when not provided", () => { + const agent = createMockAgent({ status: "running" }); + agentStore.addAgent(conversationId, agent); + + agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false); + + const agents = get(getAgentsForConversation(conversationId)); + expect(agents[0].lastAssistantMessage).toBeUndefined(); + }); }); describe("markAllErrored", () => { diff --git a/src/lib/stores/agents.ts b/src/lib/stores/agents.ts index 406a4ca..4ea5251 100644 --- a/src/lib/stores/agents.ts +++ b/src/lib/stores/agents.ts @@ -45,7 +45,13 @@ function createAgentStore() { }); }, - endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) { + endAgent( + conversationId: string, + toolUseId: string, + endedAt: number, + isError: boolean, + lastAssistantMessage?: string + ) { agentsByConversation.update((state) => { const agents = state[conversationId]; if (!agents) return state; @@ -62,6 +68,7 @@ function createAgentStore() { endedAt, status: isError ? "errored" : "completed", durationMs, + lastAssistantMessage, }; return { diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 072e8f8..ad26771 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -474,10 +474,17 @@ export async function initializeTauriListeners() { unlisteners.push(agentUpdateUnlisten); const agentEndUnlisten = await listen("claude:agent-end", (event) => { - const { tool_use_id, ended_at, is_error, conversation_id } = event.payload; + const { tool_use_id, ended_at, is_error, conversation_id, last_assistant_message } = + event.payload; const targetConversationId = conversation_id || get(claudeStore.activeConversationId); if (targetConversationId) { - agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error); + agentStore.endAgent( + targetConversationId, + tool_use_id, + ended_at, + is_error, + last_assistant_message + ); } }); unlisteners.push(agentEndUnlisten); diff --git a/src/lib/types/agents.ts b/src/lib/types/agents.ts index 2cb53cd..cc9e3b5 100644 --- a/src/lib/types/agents.ts +++ b/src/lib/types/agents.ts @@ -12,6 +12,7 @@ export interface AgentInfo { durationMs?: number; characterName: string; characterAvatar: string; + lastAssistantMessage?: string; } export interface AgentStartPayload { @@ -31,4 +32,5 @@ export interface AgentEndPayload { conversation_id?: string; duration_ms?: number; num_turns?: number; + last_assistant_message?: string; }