generated from nhcarrigan/template
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
This commit is contained in:
@@ -305,6 +305,8 @@ pub struct AgentEndEvent {
|
|||||||
pub duration_ms: Option<u64>,
|
pub duration_ms: Option<u64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub num_turns: Option<u32>,
|
pub num_turns: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_assistant_message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
+107
-11
@@ -755,6 +755,7 @@ fn handle_stderr(
|
|||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
duration_ms: None,
|
duration_ms: None,
|
||||||
num_turns: None,
|
num_turns: None,
|
||||||
|
last_assistant_message: stop_data.last_assistant_message,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -828,24 +829,49 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct SubagentStopData {
|
struct SubagentStopData {
|
||||||
parent_tool_use_id: Option<String>,
|
parent_tool_use_id: Option<String>,
|
||||||
|
last_assistant_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String> {
|
||||||
|
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<SubagentStopData> {
|
fn parse_subagent_stop_hook(line: &str) -> Option<SubagentStopData> {
|
||||||
// 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 = extract_debug_string_value(line, "parent_tool_use_id");
|
||||||
let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") {
|
let last_assistant_message = extract_debug_string_value(line, "last_assistant_message");
|
||||||
line.split("parent_tool_use_id=Some(\"")
|
|
||||||
.nth(1)?
|
|
||||||
.split('"')
|
|
||||||
.next()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(SubagentStopData {
|
Some(SubagentStopData {
|
||||||
parent_tool_use_id,
|
parent_tool_use_id,
|
||||||
|
last_assistant_message,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1169,6 +1195,7 @@ fn process_json_line(
|
|||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
duration_ms: None,
|
duration_ms: None,
|
||||||
num_turns: None,
|
num_turns: None,
|
||||||
|
last_assistant_message: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1604,6 +1631,7 @@ fn process_json_line(
|
|||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
duration_ms: None,
|
duration_ms: None,
|
||||||
num_turns: None,
|
num_turns: None,
|
||||||
|
last_assistant_message: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2155,5 +2183,73 @@ mod tests {
|
|||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
let data = result.unwrap();
|
let data = result.unwrap();
|
||||||
assert_eq!(data.parent_tool_use_id, None);
|
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())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,6 +318,16 @@
|
|||||||
<span class="text-[10px] text-red-400">Errored / Killed</span>
|
<span class="text-[10px] text-red-400">Errored / Killed</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Last assistant message snippet -->
|
||||||
|
{#if agent.lastAssistantMessage}
|
||||||
|
<p
|
||||||
|
class="mt-1 text-[10px] text-[var(--text-secondary)] italic truncate"
|
||||||
|
title={agent.lastAssistantMessage}
|
||||||
|
>
|
||||||
|
{agent.lastAssistantMessage}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -177,6 +177,32 @@ describe("agents store", () => {
|
|||||||
const agents = get(getAgentsForConversation(conversationId));
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
expect(agents[0].status).toBe("running"); // Status unchanged
|
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", () => {
|
describe("markAllErrored", () => {
|
||||||
|
|||||||
@@ -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) => {
|
agentsByConversation.update((state) => {
|
||||||
const agents = state[conversationId];
|
const agents = state[conversationId];
|
||||||
if (!agents) return state;
|
if (!agents) return state;
|
||||||
@@ -62,6 +68,7 @@ function createAgentStore() {
|
|||||||
endedAt,
|
endedAt,
|
||||||
status: isError ? "errored" : "completed",
|
status: isError ? "errored" : "completed",
|
||||||
durationMs,
|
durationMs,
|
||||||
|
lastAssistantMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+9
-2
@@ -474,10 +474,17 @@ export async function initializeTauriListeners() {
|
|||||||
unlisteners.push(agentUpdateUnlisten);
|
unlisteners.push(agentUpdateUnlisten);
|
||||||
|
|
||||||
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
|
const agentEndUnlisten = await listen<AgentEndPayload>("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);
|
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||||
if (targetConversationId) {
|
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);
|
unlisteners.push(agentEndUnlisten);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface AgentInfo {
|
|||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
characterName: string;
|
characterName: string;
|
||||||
characterAvatar: string;
|
characterAvatar: string;
|
||||||
|
lastAssistantMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentStartPayload {
|
export interface AgentStartPayload {
|
||||||
@@ -31,4 +32,5 @@ export interface AgentEndPayload {
|
|||||||
conversation_id?: string;
|
conversation_id?: string;
|
||||||
duration_ms?: number;
|
duration_ms?: number;
|
||||||
num_turns?: number;
|
num_turns?: number;
|
||||||
|
last_assistant_message?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user