From e6b491bcf1864aeb6e5b7c324a661747adf19f7f Mon Sep 17 00:00:00 2001
From: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
Date: Sun, 3 May 2026 19:51:46 -0700
Subject: [PATCH] feat(threads): surface live subagent work in parent thread
(#1122) (#1159)
---
.../components/ToolTimelineBlock.tsx | 92 ++++++++++-
.../__tests__/ToolTimelineBlock.test.tsx | 115 +++++++++++++
app/src/providers/ChatRuntimeProvider.tsx | 108 +++++++++++--
.../__tests__/ChatRuntimeProvider.test.tsx | 141 ++++++++++++++++
.../services/__tests__/chatService.test.ts | 92 +++++++++++
app/src/services/chatService.ts | 135 +++++++++++++++-
app/src/store/chatRuntimeSlice.ts | 55 +++++++
src/core/socketio.rs | 60 +++++++
src/openhuman/agent/harness/fork_context.rs | 10 ++
src/openhuman/agent/harness/session/turn.rs | 1 +
.../agent/harness/subagent_runner/ops.rs | 54 ++++++-
.../harness/subagent_runner/ops_tests.rs | 95 +++++++++++
src/openhuman/agent/progress.rs | 65 +++++++-
src/openhuman/agent/triage/escalation.rs | 4 +
src/openhuman/channels/proactive.rs | 1 +
.../channels/providers/presentation.rs | 3 +
src/openhuman/channels/providers/web.rs | 151 +++++++++++++-----
.../tools/impl/agent/spawn_subagent.rs | 44 ++++-
tests/agent_harness_public.rs | 1 +
tests/calendar_grounding_e2e.rs | 1 +
20 files changed, 1170 insertions(+), 58 deletions(-)
create mode 100644 app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx
diff --git a/app/src/pages/conversations/components/ToolTimelineBlock.tsx b/app/src/pages/conversations/components/ToolTimelineBlock.tsx
index 0c436a48b..0ebd10dca 100644
--- a/app/src/pages/conversations/components/ToolTimelineBlock.tsx
+++ b/app/src/pages/conversations/components/ToolTimelineBlock.tsx
@@ -1,8 +1,85 @@
-import type { ToolTimelineEntry } from '../../../store/chatRuntimeSlice';
+import type { SubagentActivity, ToolTimelineEntry } from '../../../store/chatRuntimeSlice';
import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting';
import { parseWorkerThreadRef } from '../utils/workerThreadRef';
import { WorkerThreadRefCard } from './WorkerThreadRefCard';
+/**
+ * Render the live activity of one running (or completed) sub-agent
+ * inside its parent timeline row — the mode/dedicated-thread badge,
+ * the child iteration counter, the final-run statistics, and the
+ * flat list of child tool calls the sub-agent has executed.
+ *
+ * Kept as a sibling of the existing worker-thread / detail block so
+ * the surrounding `` chevron + status pill behaviour is
+ * unaffected — this component only renders when `subagent` is
+ * present on the entry, which is true for any row produced by the
+ * `subagent_*` socket events from a current core.
+ */
+export function SubagentActivityBlock({ subagent }: { subagent: SubagentActivity }) {
+ const headerBits: string[] = [];
+ if (subagent.mode) headerBits.push(subagent.mode);
+ if (subagent.dedicatedThread) headerBits.push('worker thread');
+ if (subagent.childIteration != null && subagent.childMaxIterations != null) {
+ headerBits.push(`turn ${subagent.childIteration}/${subagent.childMaxIterations}`);
+ } else if (subagent.iterations != null) {
+ headerBits.push(`${subagent.iterations} turn${subagent.iterations === 1 ? '' : 's'}`);
+ }
+ if (subagent.elapsedMs != null) {
+ headerBits.push(
+ subagent.elapsedMs >= 1000
+ ? `${(subagent.elapsedMs / 1000).toFixed(1)}s`
+ : `${subagent.elapsedMs}ms`
+ );
+ }
+ return (
+
+ {headerBits.length > 0 ? (
+
+ {headerBits.map(bit => (
+
+ {bit}
+
+ ))}
+
+ ) : null}
+ {subagent.toolCalls.length > 0 ? (
+
+ {subagent.toolCalls.map(call => {
+ const tone =
+ call.status === 'running'
+ ? 'text-amber-700'
+ : call.status === 'success'
+ ? 'text-sage-700'
+ : 'text-coral-700';
+ return (
+ -
+ •
+ {call.toolName}
+ {call.iteration != null ? (
+ ·t{call.iteration}
+ ) : null}
+ {call.status}
+ {call.elapsedMs != null && call.status !== 'running' ? (
+
+ {call.elapsedMs >= 1000
+ ? `${(call.elapsedMs / 1000).toFixed(1)}s`
+ : `${call.elapsedMs}ms`}
+
+ ) : null}
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
+
export function ToolTimelineBlock({ entries }: { entries: ToolTimelineEntry[] }) {
const latestRunningEntryId = [...entries].reverse().find(entry => entry.status === 'running')?.id;
@@ -21,6 +98,12 @@ export function ToolTimelineBlock({ entries }: { entries: ToolTimelineEntry[] })
const detailContent =
normalizeToolBody(formatted.detail) ?? normalizeToolBody(entry.argsBuffer);
const workerRef = parseWorkerThreadRef(formatted.detail ?? entry.detail);
+ const subagent = entry.subagent;
+ // A subagent row should always render the expandable details so
+ // its live activity is visible — even when there is no prompt
+ // detail to show. Mirrors the rule that a non-subagent row only
+ // expands when it has detail content.
+ const expandable = detailContent != null || subagent != null;
const shouldAutoExpand = latestRunningEntryId != null && latestRunningEntryId === entry.id;
const statusTone =
entry.status === 'running'
@@ -46,7 +129,7 @@ export function ToolTimelineBlock({ entries }: { entries: ToolTimelineEntry[] })
return (
- {detailContent ? (
+ {expandable ? (
{formatted.detail}
- ) : (
+ ) : detailContent ? (
{detailContent}
- )}
+ ) : null}
+ {subagent ? : null}
) : (
diff --git a/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx
new file mode 100644
index 000000000..e5e8a7c5a
--- /dev/null
+++ b/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx
@@ -0,0 +1,115 @@
+import { render, screen } from '@testing-library/react';
+import { Provider } from 'react-redux';
+import { describe, expect, it } from 'vitest';
+
+import { store } from '../../../../store';
+import type { ToolTimelineEntry } from '../../../../store/chatRuntimeSlice';
+import { SubagentActivityBlock, ToolTimelineBlock } from '../ToolTimelineBlock';
+
+// #1122 — guards the parent-thread live subagent rendering. The block
+// always expands subagent rows so the activity stays visible while the
+// run is in flight, even before the subagent emits any prompt detail.
+
+function renderInStore(ui: React.ReactNode) {
+ return render(
{ui});
+}
+
+describe('SubagentActivityBlock', () => {
+ it('renders mode + dedicated-thread + child-turn pills', () => {
+ renderInStore(
+
+ );
+ const block = screen.getByTestId('subagent-activity');
+ expect(block.textContent).toContain('typed');
+ expect(block.textContent).toContain('worker thread');
+ expect(block.textContent).toContain('turn 2/5');
+ });
+
+ it('renders final-run statistics on a completed sub-agent', () => {
+ renderInStore(
+
+ );
+ const block = screen.getByTestId('subagent-activity');
+ expect(block.textContent).toContain('3 turns');
+ expect(block.textContent).toContain('4.2s');
+ });
+
+ it('renders one row per child tool call with status + timing', () => {
+ renderInStore(
+
+ );
+ const calls = screen.getAllByTestId('subagent-tool-call');
+ expect(calls).toHaveLength(3);
+ expect(calls[0].textContent).toContain('web_search');
+ expect(calls[0].textContent).toContain('success');
+ expect(calls[0].textContent).toContain('312ms');
+ expect(calls[1].textContent).toContain('running');
+ expect(calls[1].textContent).toContain('·t2');
+ expect(calls[2].textContent).toContain('error');
+ });
+});
+
+describe('ToolTimelineBlock — subagent rendering', () => {
+ it('expands a subagent row even without prompt detail and shows child tool calls', () => {
+ const entry: ToolTimelineEntry = {
+ id: 'tid:subagent:sub-1:researcher',
+ name: 'subagent:researcher',
+ round: 1,
+ status: 'running',
+ subagent: {
+ taskId: 'sub-1',
+ agentId: 'researcher',
+ mode: 'typed',
+ childIteration: 1,
+ childMaxIterations: 5,
+ toolCalls: [{ callId: 'cc-1', toolName: 'web_search', status: 'running', iteration: 1 }],
+ },
+ };
+ renderInStore(
);
+
+ const calls = screen.getAllByTestId('subagent-tool-call');
+ expect(calls).toHaveLength(1);
+ expect(calls[0].textContent).toContain('web_search');
+ expect(screen.getByTestId('subagent-activity').textContent).toContain('turn 1/5');
+ });
+
+ it('renders a non-subagent row without crashing when there is no detail', () => {
+ const entry: ToolTimelineEntry = {
+ id: 'plain',
+ name: 'list_threads',
+ round: 0,
+ status: 'success',
+ };
+ renderInStore(
);
+ // Plain rows with no detail collapse to a flat label + status pill.
+ expect(screen.queryByTestId('subagent-activity')).toBeNull();
+ });
+});
diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx
index 440338d79..ae9c709df 100644
--- a/app/src/providers/ChatRuntimeProvider.tsx
+++ b/app/src/providers/ChatRuntimeProvider.tsx
@@ -8,7 +8,6 @@ import {
type ChatIterationStartEvent,
type ChatSegmentEvent,
type ChatSubagentDoneEvent,
- type ChatSubagentSpawnedEvent,
type ChatToolCallEvent,
type ChatToolResultEvent,
type ProactiveMessageEvent,
@@ -334,7 +333,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
})
);
},
- onSubagentSpawned: (event: ChatSubagentSpawnedEvent) => {
+ onSubagentSpawned: event => {
const prev = store.getState().chatRuntime.inferenceStatusByThread[event.thread_id];
dispatch(
setInferenceStatusForThread({
@@ -361,6 +360,13 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
status: 'running',
detail: pendingContext.prompt,
sourceToolName: pendingContext.sourceToolName,
+ subagent: {
+ taskId: event.skill_id,
+ agentId: event.tool_name,
+ mode: event.subagent?.mode,
+ dedicatedThread: event.subagent?.dedicated_thread,
+ toolCalls: [],
+ },
}),
],
})
@@ -370,14 +376,21 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
const subagentRowId = `${event.thread_id}:subagent:${event.skill_id}:${event.tool_name}`;
const existing = store.getState().chatRuntime.toolTimelineByThread[event.thread_id] ?? [];
if (existing.length > 0) {
- const entries = existing.map(entry =>
- entry.id === subagentRowId && entry.status === 'running'
- ? decorateEntry({
- ...entry,
- status: (event.success ? 'success' : 'error') as ToolTimelineEntryStatus,
- })
- : entry
- );
+ const entries = existing.map(entry => {
+ if (entry.id !== subagentRowId || entry.status !== 'running') return entry;
+ return decorateEntry({
+ ...entry,
+ status: (event.success ? 'success' : 'error') as ToolTimelineEntryStatus,
+ subagent: entry.subagent
+ ? {
+ ...entry.subagent,
+ iterations: event.subagent?.iterations ?? entry.subagent.iterations,
+ elapsedMs: event.subagent?.elapsed_ms ?? entry.subagent.elapsedMs,
+ outputChars: event.subagent?.output_chars ?? entry.subagent.outputChars,
+ }
+ : entry.subagent,
+ });
+ });
dispatch(setToolTimelineForThread({ threadId: event.thread_id, entries }));
}
@@ -390,6 +403,81 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
})
);
},
+ onSubagentIterationStart: event => {
+ const taskId = event.subagent?.task_id ?? event.skill_id;
+ const agentId = event.subagent?.agent_id ?? event.tool_name;
+ const rowId = `${event.thread_id}:subagent:${taskId}:${agentId}`;
+ const existing = store.getState().chatRuntime.toolTimelineByThread[event.thread_id] ?? [];
+ const idx = existing.findIndex(entry => entry.id === rowId);
+ if (idx < 0) return;
+ const entry = existing[idx];
+ if (!entry.subagent) return;
+ const next = [...existing];
+ next[idx] = {
+ ...entry,
+ subagent: {
+ ...entry.subagent,
+ childIteration: event.subagent?.child_iteration ?? entry.subagent.childIteration,
+ childMaxIterations:
+ event.subagent?.child_max_iterations ?? entry.subagent.childMaxIterations,
+ },
+ };
+ dispatch(setToolTimelineForThread({ threadId: event.thread_id, entries: next }));
+ },
+ onSubagentToolCall: event => {
+ const taskId = event.subagent?.task_id ?? event.skill_id;
+ const agentId = event.subagent?.agent_id;
+ if (!agentId) return;
+ const rowId = `${event.thread_id}:subagent:${taskId}:${agentId}`;
+ const existing = store.getState().chatRuntime.toolTimelineByThread[event.thread_id] ?? [];
+ const idx = existing.findIndex(entry => entry.id === rowId);
+ if (idx < 0) return;
+ const entry = existing[idx];
+ if (!entry.subagent) return;
+ // De-dupe on call_id — the same call should not append twice if
+ // the socket layer redelivers (e.g. on reconnect during a run).
+ if (entry.subagent.toolCalls.some(c => c.callId === event.tool_call_id)) return;
+ const next = [...existing];
+ next[idx] = {
+ ...entry,
+ subagent: {
+ ...entry.subagent,
+ toolCalls: [
+ ...entry.subagent.toolCalls,
+ {
+ callId: event.tool_call_id,
+ toolName: event.tool_name,
+ status: 'running',
+ iteration: event.subagent?.child_iteration,
+ },
+ ],
+ },
+ };
+ dispatch(setToolTimelineForThread({ threadId: event.thread_id, entries: next }));
+ },
+ onSubagentToolResult: event => {
+ const taskId = event.subagent?.task_id ?? event.skill_id;
+ const agentId = event.subagent?.agent_id;
+ if (!agentId) return;
+ const rowId = `${event.thread_id}:subagent:${taskId}:${agentId}`;
+ const existing = store.getState().chatRuntime.toolTimelineByThread[event.thread_id] ?? [];
+ const idx = existing.findIndex(entry => entry.id === rowId);
+ if (idx < 0) return;
+ const entry = existing[idx];
+ if (!entry.subagent) return;
+ const callIdx = entry.subagent.toolCalls.findIndex(c => c.callId === event.tool_call_id);
+ if (callIdx < 0) return;
+ const updatedCalls = [...entry.subagent.toolCalls];
+ updatedCalls[callIdx] = {
+ ...updatedCalls[callIdx],
+ status: event.success ? 'success' : 'error',
+ elapsedMs: event.subagent?.elapsed_ms ?? updatedCalls[callIdx].elapsedMs,
+ outputChars: event.subagent?.output_chars ?? updatedCalls[callIdx].outputChars,
+ };
+ const next = [...existing];
+ next[idx] = { ...entry, subagent: { ...entry.subagent, toolCalls: updatedCalls } };
+ dispatch(setToolTimelineForThread({ threadId: event.thread_id, entries: next }));
+ },
onSegment: (event: ChatSegmentEvent) => {
const eventKey = `segment:${event.thread_id}:${event.request_id}:${event.segment_index}`;
if (
diff --git a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
index 1c079dcb2..1acb4a2e0 100644
--- a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
+++ b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
@@ -358,4 +358,145 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
expect(store.getState().chatRuntime.inferenceStatusByThread['t-err']).toBeUndefined();
});
});
+
+ // Live subagent activity (#1122) — the parent thread surfaces a
+ // subagent's child iterations and tool calls as they happen, then
+ // settles to the final-run statistics on completion. The asserts here
+ // are the contract the ToolTimelineBlock UI relies on; if a refactor
+ // moves the subagent state somewhere else this test is the canary.
+ describe('live subagent activity (#1122)', () => {
+ it('builds a live subagent block from spawned → iteration → tool call → done', () => {
+ const listeners = renderProvider();
+ const threadId = 'tsa';
+
+ act(() => {
+ listeners.onSubagentSpawned?.({
+ thread_id: threadId,
+ request_id: 'r1',
+ tool_name: 'researcher',
+ skill_id: 'sub-1',
+ message: 'spawned',
+ round: 1,
+ subagent: { mode: 'typed', dedicated_thread: false, prompt_chars: 42 },
+ });
+ });
+
+ let timeline = store.getState().chatRuntime.toolTimelineByThread[threadId] ?? [];
+ expect(timeline).toHaveLength(1);
+ expect(timeline[0]?.subagent).toMatchObject({
+ agentId: 'researcher',
+ taskId: 'sub-1',
+ mode: 'typed',
+ dedicatedThread: false,
+ toolCalls: [],
+ });
+
+ act(() => {
+ listeners.onSubagentIterationStart?.({
+ thread_id: threadId,
+ request_id: 'r1',
+ round: 1,
+ tool_name: 'researcher',
+ skill_id: 'sub-1',
+ message: 'iter',
+ subagent: {
+ agent_id: 'researcher',
+ task_id: 'sub-1',
+ child_iteration: 1,
+ child_max_iterations: 5,
+ },
+ });
+ listeners.onSubagentToolCall?.({
+ thread_id: threadId,
+ request_id: 'r1',
+ round: 1,
+ tool_name: 'web_search',
+ skill_id: 'sub-1',
+ tool_call_id: 'cc-1',
+ subagent: { agent_id: 'researcher', task_id: 'sub-1', child_iteration: 1 },
+ });
+ // Duplicate child tool_call must not double-append.
+ listeners.onSubagentToolCall?.({
+ thread_id: threadId,
+ request_id: 'r1',
+ round: 1,
+ tool_name: 'web_search',
+ skill_id: 'sub-1',
+ tool_call_id: 'cc-1',
+ subagent: { agent_id: 'researcher', task_id: 'sub-1', child_iteration: 1 },
+ });
+ });
+
+ timeline = store.getState().chatRuntime.toolTimelineByThread[threadId] ?? [];
+ expect(timeline[0]?.subagent?.childIteration).toBe(1);
+ expect(timeline[0]?.subagent?.childMaxIterations).toBe(5);
+ expect(timeline[0]?.subagent?.toolCalls).toEqual([
+ { callId: 'cc-1', toolName: 'web_search', status: 'running', iteration: 1 },
+ ]);
+
+ act(() => {
+ listeners.onSubagentToolResult?.({
+ thread_id: threadId,
+ request_id: 'r1',
+ round: 1,
+ tool_name: 'web_search',
+ skill_id: 'sub-1',
+ tool_call_id: 'cc-1',
+ success: true,
+ subagent: {
+ agent_id: 'researcher',
+ task_id: 'sub-1',
+ child_iteration: 1,
+ elapsed_ms: 312,
+ output_chars: 1280,
+ },
+ });
+ listeners.onSubagentDone?.({
+ thread_id: threadId,
+ request_id: 'r1',
+ tool_name: 'researcher',
+ skill_id: 'sub-1',
+ message: 'done',
+ success: true,
+ round: 1,
+ subagent: { iterations: 2, elapsed_ms: 4200, output_chars: 980 },
+ });
+ });
+
+ timeline = store.getState().chatRuntime.toolTimelineByThread[threadId] ?? [];
+ expect(timeline[0]?.status).toBe('success');
+ expect(timeline[0]?.subagent?.toolCalls[0]).toMatchObject({
+ status: 'success',
+ elapsedMs: 312,
+ outputChars: 1280,
+ });
+ expect(timeline[0]?.subagent).toMatchObject({
+ iterations: 2,
+ elapsedMs: 4200,
+ outputChars: 980,
+ });
+ });
+
+ it('ignores subagent_tool_call events that arrive before subagent_spawned', () => {
+ const listeners = renderProvider();
+ const threadId = 'tsa-orphan';
+
+ act(() => {
+ listeners.onSubagentToolCall?.({
+ thread_id: threadId,
+ request_id: 'r1',
+ round: 1,
+ tool_name: 'web_search',
+ skill_id: 'sub-missing',
+ tool_call_id: 'cc-1',
+ subagent: { agent_id: 'researcher', task_id: 'sub-missing', child_iteration: 1 },
+ });
+ });
+
+ // No row was created — the orphan child tool call is dropped rather
+ // than synthesising a partial subagent row from incomplete data.
+ const timeline = store.getState().chatRuntime.toolTimelineByThread[threadId] ?? [];
+ expect(timeline).toHaveLength(0);
+ });
+ });
});
diff --git a/app/src/services/__tests__/chatService.test.ts b/app/src/services/__tests__/chatService.test.ts
index c3ff5086f..c133fb4d2 100644
--- a/app/src/services/__tests__/chatService.test.ts
+++ b/app/src/services/__tests__/chatService.test.ts
@@ -76,6 +76,98 @@ describe('chatService.subscribeChatEvents', () => {
expect(onDone).toHaveBeenCalledTimes(1);
});
+ // #1122 — the new live subagent events must be wired up under their
+ // canonical snake_case names and dispatch payloads back through the
+ // listener interface unchanged. Without this coverage the parent
+ // thread's live subagent block silently goes blank if a future
+ // refactor renames a socket event.
+ it('subscribes and forwards live subagent events under canonical names', () => {
+ const socket = createMockSocket();
+ vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
+
+ const onSubagentSpawned = vi.fn();
+ const onSubagentDone = vi.fn();
+ const onSubagentIterationStart = vi.fn();
+ const onSubagentToolCall = vi.fn();
+ const onSubagentToolResult = vi.fn();
+
+ subscribeChatEvents({
+ onSubagentSpawned,
+ onSubagentDone,
+ onSubagentIterationStart,
+ onSubagentToolCall,
+ onSubagentToolResult,
+ });
+
+ const subscribedEvents = socket.on.mock.calls.map(call => call[0]);
+ expect(subscribedEvents).toEqual([
+ 'subagent_spawned',
+ 'subagent_completed',
+ 'subagent_failed',
+ 'subagent_iteration_start',
+ 'subagent_tool_call',
+ 'subagent_tool_result',
+ ]);
+
+ const spawned = {
+ thread_id: 't',
+ request_id: 'r',
+ tool_name: 'researcher',
+ skill_id: 'sub-1',
+ message: 'm',
+ round: 1,
+ subagent: { mode: 'typed' },
+ };
+ socket.emit('subagent_spawned', spawned);
+ expect(onSubagentSpawned).toHaveBeenCalledWith(spawned);
+
+ const iter = {
+ thread_id: 't',
+ request_id: 'r',
+ round: 1,
+ tool_name: 'researcher',
+ skill_id: 'sub-1',
+ message: 'iter',
+ subagent: {
+ agent_id: 'researcher',
+ task_id: 'sub-1',
+ child_iteration: 1,
+ child_max_iterations: 5,
+ },
+ };
+ socket.emit('subagent_iteration_start', iter);
+ expect(onSubagentIterationStart).toHaveBeenCalledWith(iter);
+
+ const call = {
+ thread_id: 't',
+ request_id: 'r',
+ round: 1,
+ tool_name: 'web_search',
+ skill_id: 'sub-1',
+ tool_call_id: 'cc-1',
+ subagent: { agent_id: 'researcher', task_id: 'sub-1', child_iteration: 1 },
+ };
+ socket.emit('subagent_tool_call', call);
+ expect(onSubagentToolCall).toHaveBeenCalledWith(call);
+
+ socket.emit('subagent_tool_result', { ...call, success: true });
+ expect(onSubagentToolResult).toHaveBeenCalledWith({ ...call, success: true });
+
+ // Both completion paths route through the same listener.
+ const done = {
+ thread_id: 't',
+ request_id: 'r',
+ tool_name: 'researcher',
+ skill_id: 'sub-1',
+ message: 'done',
+ success: true,
+ round: 1,
+ };
+ socket.emit('subagent_completed', done);
+ socket.emit('subagent_failed', { ...done, success: false });
+ expect(onSubagentDone).toHaveBeenCalledTimes(2);
+ });
+
it('removes all handlers on cleanup', () => {
const socket = createMockSocket();
vi.mocked(socketService.getSocket).mockReturnValue(socket as never);
diff --git a/app/src/services/chatService.ts b/app/src/services/chatService.ts
index 283c43f90..9ca58dc1a 100644
--- a/app/src/services/chatService.ts
+++ b/app/src/services/chatService.ts
@@ -137,6 +137,82 @@ export interface ChatSubagentDoneEvent {
message: string;
success: boolean;
round: number;
+ /** Per-event subagent detail. Mirrors `SubagentProgressDetail` in core. */
+ subagent?: SubagentProgressDetail;
+}
+
+/**
+ * Per-event subagent detail attached to live subagent activity events
+ * (`subagent_spawned`, `subagent_completed`, `subagent_iteration_start`,
+ * `subagent_tool_call`, `subagent_tool_result`).
+ *
+ * Matches the Rust `SubagentProgressDetail` struct in
+ * `src/core/socketio.rs` — every field is optional so older cores that
+ * don't emit it stay parseable.
+ */
+export interface SubagentProgressDetail {
+ mode?: string;
+ dedicated_thread?: boolean;
+ prompt_chars?: number;
+ child_iteration?: number;
+ child_max_iterations?: number;
+ agent_id?: string;
+ task_id?: string;
+ elapsed_ms?: number;
+ iterations?: number;
+ output_chars?: number;
+}
+
+/** Extended payload for `subagent_spawned`. */
+export interface ChatSubagentSpawnedEventV2 extends ChatSubagentSpawnedEvent {
+ subagent?: SubagentProgressDetail;
+}
+
+/**
+ * Emitted at the start of each LLM iteration *inside* a running
+ * sub-agent. Lets the parent thread surface child progress (which round
+ * the subagent is on, its iteration cap) without flattening it into the
+ * parent's own iteration counter.
+ */
+export interface ChatSubagentIterationStartEvent {
+ thread_id: string;
+ request_id: string;
+ /** Parent's iteration index (inherited from the parent context). */
+ round: number;
+ /** Subagent's agent id. Mirrored on the flat `tool_name` field. */
+ tool_name: string;
+ /** Subagent's task id (the spawn id). */
+ skill_id: string;
+ message: string;
+ subagent?: SubagentProgressDetail;
+}
+
+/** Emitted when a sub-agent starts executing one of its own tools. */
+export interface ChatSubagentToolCallEvent {
+ thread_id: string;
+ request_id: string;
+ round: number;
+ /** Child's tool name (e.g. `composio_execute`, `web_search`). */
+ tool_name: string;
+ /** Subagent's task id. */
+ skill_id: string;
+ /** Provider-assigned tool call id. */
+ tool_call_id: string;
+ subagent?: SubagentProgressDetail;
+}
+
+/** Emitted when a sub-agent's tool execution finishes. */
+export interface ChatSubagentToolResultEvent {
+ thread_id: string;
+ request_id: string;
+ round: number;
+ tool_name: string;
+ skill_id: string;
+ tool_call_id: string;
+ success: boolean;
+ /** Stringified JSON `{ output_chars, elapsed_ms }` matching `tool_result`. */
+ output?: string;
+ subagent?: SubagentProgressDetail;
}
/**
@@ -187,8 +263,11 @@ export interface ChatEventListeners {
onIterationStart?: (event: ChatIterationStartEvent) => void;
onToolCall?: (event: ChatToolCallEvent) => void;
onToolResult?: (event: ChatToolResultEvent) => void;
- onSubagentSpawned?: (event: ChatSubagentSpawnedEvent) => void;
+ onSubagentSpawned?: (event: ChatSubagentSpawnedEventV2) => void;
onSubagentDone?: (event: ChatSubagentDoneEvent) => void;
+ onSubagentIterationStart?: (event: ChatSubagentIterationStartEvent) => void;
+ onSubagentToolCall?: (event: ChatSubagentToolCallEvent) => void;
+ onSubagentToolResult?: (event: ChatSubagentToolResultEvent) => void;
onSegment?: (event: ChatSegmentEvent) => void;
onTextDelta?: (event: ChatTextDeltaEvent) => void;
onThinkingDelta?: (event: ChatThinkingDeltaEvent) => void;
@@ -214,6 +293,9 @@ export function subscribeChatEvents(listeners: ChatEventListeners): () => void {
subagentSpawned: 'subagent_spawned',
subagentCompleted: 'subagent_completed',
subagentFailed: 'subagent_failed',
+ subagentIterationStart: 'subagent_iteration_start',
+ subagentToolCall: 'subagent_tool_call',
+ subagentToolResult: 'subagent_tool_result',
segment: 'chat_segment',
textDelta: 'text_delta',
thinkingDelta: 'thinking_delta',
@@ -335,6 +417,57 @@ export function subscribeChatEvents(listeners: ChatEventListeners): () => void {
handlers.push([EVENTS.subagentFailed, onFailed]);
}
+ if (listeners.onSubagentIterationStart) {
+ const cb = (payload: unknown) => {
+ const e = payload as ChatSubagentIterationStartEvent;
+ chatLog(
+ '%s thread_id=%s task=%s child_round=%s/%s',
+ EVENTS.subagentIterationStart,
+ e.thread_id,
+ e.skill_id,
+ e.subagent?.child_iteration,
+ e.subagent?.child_max_iterations
+ );
+ listeners.onSubagentIterationStart?.(e);
+ };
+ socket.on(EVENTS.subagentIterationStart, cb);
+ handlers.push([EVENTS.subagentIterationStart, cb]);
+ }
+
+ if (listeners.onSubagentToolCall) {
+ const cb = (payload: unknown) => {
+ const e = payload as ChatSubagentToolCallEvent;
+ chatLog(
+ '%s thread_id=%s task=%s child_tool=%s call_id=%s',
+ EVENTS.subagentToolCall,
+ e.thread_id,
+ e.skill_id,
+ e.tool_name,
+ e.tool_call_id
+ );
+ listeners.onSubagentToolCall?.(e);
+ };
+ socket.on(EVENTS.subagentToolCall, cb);
+ handlers.push([EVENTS.subagentToolCall, cb]);
+ }
+
+ if (listeners.onSubagentToolResult) {
+ const cb = (payload: unknown) => {
+ const e = payload as ChatSubagentToolResultEvent;
+ chatLog(
+ '%s thread_id=%s task=%s child_tool=%s success=%s',
+ EVENTS.subagentToolResult,
+ e.thread_id,
+ e.skill_id,
+ e.tool_name,
+ e.success
+ );
+ listeners.onSubagentToolResult?.(e);
+ };
+ socket.on(EVENTS.subagentToolResult, cb);
+ handlers.push([EVENTS.subagentToolResult, cb]);
+ }
+
if (listeners.onSegment) {
const cb = (payload: unknown) => {
const e = payload as ChatSegmentEvent;
diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts
index ae4695249..b46573905 100644
--- a/app/src/store/chatRuntimeSlice.ts
+++ b/app/src/store/chatRuntimeSlice.ts
@@ -12,6 +12,54 @@ export interface InferenceStatus {
activeSubagent?: string;
}
+/**
+ * Per-subagent live activity attached to a `subagent:*` timeline row.
+ *
+ * Carries everything the parent thread's UI needs to render a live
+ * subagent block — child iteration counter, mode, dedicated-thread
+ * flag, final-run statistics, and a flat list of child tool calls
+ * the subagent has executed during its run. Populated incrementally
+ * from the new `subagent_*` socket events; absent on plain (legacy)
+ * subagent rows so older snapshots stay renderable unchanged.
+ */
+export interface SubagentActivity {
+ /** Spawn task id (`sub-…`). Stable for the lifetime of one delegation. */
+ taskId: string;
+ /** Sub-agent definition id (e.g. `researcher`). */
+ agentId: string;
+ /** Resolved spawn mode — `"typed"` or `"fork"`. */
+ mode?: string;
+ /** `true` when the spawn requested a dedicated worker thread. */
+ dedicatedThread?: boolean;
+ /** Sub-agent's current 1-based iteration index (live). */
+ childIteration?: number;
+ /** Sub-agent's iteration cap. */
+ childMaxIterations?: number;
+ /** Total iterations once the sub-agent finishes. */
+ iterations?: number;
+ /** Wall-clock ms once the sub-agent finishes. */
+ elapsedMs?: number;
+ /** Character length of the final assistant text. */
+ outputChars?: number;
+ /** Child tool calls executed inside the sub-agent, in arrival order. */
+ toolCalls: SubagentToolCallEntry[];
+}
+
+/** One child tool call performed by a running sub-agent. */
+export interface SubagentToolCallEntry {
+ /** Provider-assigned tool call id. */
+ callId: string;
+ /** Child's tool name. */
+ toolName: string;
+ status: ToolTimelineEntryStatus;
+ /** 1-based child iteration the call belongs to. */
+ iteration?: number;
+ /** Wall-clock ms the call took (set on completion). */
+ elapsedMs?: number;
+ /** Character length of the tool result (set on completion). */
+ outputChars?: number;
+}
+
export interface ToolTimelineEntry {
id: string;
name: string;
@@ -21,6 +69,13 @@ export interface ToolTimelineEntry {
displayName?: string;
detail?: string;
sourceToolName?: string;
+ /**
+ * Live sub-agent activity for `subagent:*` rows. Built up from the
+ * `subagent_iteration_start` / `subagent_tool_call` /
+ * `subagent_tool_result` socket events. Absent for non-subagent
+ * rows and for legacy snapshots emitted by older cores.
+ */
+ subagent?: SubagentActivity;
}
export interface StreamingAssistantState {
diff --git a/src/core/socketio.rs b/src/core/socketio.rs
index 973a0ba67..05698d37b 100644
--- a/src/core/socketio.rs
+++ b/src/core/socketio.rs
@@ -72,6 +72,66 @@ pub struct WebChannelEvent {
/// Optional citations attached to `chat_done` payloads.
#[serde(skip_serializing_if = "Option::is_none")]
pub citations: Option
,
+ /// Sub-agent specific progress detail. Populated on
+ /// `subagent_spawned`, `subagent_completed`, `subagent_iteration_start`,
+ /// `subagent_tool_call`, and `subagent_tool_result` events so the UI
+ /// can attribute child activity to the parent's live subagent row
+ /// without overloading the flat top-level fields. `None` for any
+ /// non-subagent event.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub subagent: Option,
+}
+
+/// Per-event subagent progress detail attached to `WebChannelEvent`.
+///
+/// Carries the fields the parent thread's UI needs to render a live
+/// subagent block — child iteration counters, mode, child task/agent
+/// ids when distinct from the flat `tool_name` (which already carries
+/// the agent id on top-level subagent events but not on nested
+/// `subagent_tool_*` events where `tool_name` is the *child's* tool),
+/// and final-run statistics on `subagent_completed`.
+///
+/// Every field is optional and skipped from the JSON payload when
+/// absent — this keeps the wire format compact for non-subagent events
+/// (where the whole struct is `None`) and lets new fields land
+/// non-breakingly behind older clients.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct SubagentProgressDetail {
+ /// Resolved spawn mode — `"typed"` or `"fork"`.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub mode: Option,
+ /// Whether the spawn requested a dedicated worker thread.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub dedicated_thread: Option,
+ /// Character length of the delegation prompt (on `subagent_spawned`).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub prompt_chars: Option,
+ /// Sub-agent's child iteration counter (on `subagent_iteration_start`,
+ /// `subagent_tool_call`, `subagent_tool_result`). 1-based.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub child_iteration: Option,
+ /// Sub-agent's configured iteration cap.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub child_max_iterations: Option,
+ /// Child agent id (on nested `subagent_tool_*` events where the flat
+ /// `tool_name` is the child's tool, not the agent).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub agent_id: Option,
+ /// Spawn task id (on nested `subagent_tool_*` events).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub task_id: Option,
+ /// Elapsed wall-clock for the call/run in milliseconds.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub elapsed_ms: Option,
+ /// Total iterations the sub-agent used (on `subagent_completed`).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub iterations: Option,
+ /// Character length of the sub-agent's final assistant text
+ /// (on `subagent_completed`) or the tool result
+ /// (on `subagent_tool_result`).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub output_chars: Option,
}
#[derive(Debug, Deserialize)]
diff --git a/src/openhuman/agent/harness/fork_context.rs b/src/openhuman/agent/harness/fork_context.rs
index 5d8cf0a73..8a8864d38 100644
--- a/src/openhuman/agent/harness/fork_context.rs
+++ b/src/openhuman/agent/harness/fork_context.rs
@@ -17,6 +17,7 @@
//! Both contexts are stashed in `Arc`s so that cloning into the child
//! costs a refcount bump rather than a full copy.
+use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::config::AgentConfig;
use crate::openhuman::memory::Memory;
use crate::openhuman::providers::{ChatMessage, Provider};
@@ -118,6 +119,15 @@ pub struct ParentExecutionContext {
/// `Some(parent.session_key)`. A grand-child observes
/// `Some("{grandparent_key}__{parent_key}")`.
pub session_parent_prefix: Option,
+
+ /// Parent's progress sink. When set, the sub-agent runner emits
+ /// `AgentProgress::Subagent*` lifecycle events through this channel
+ /// so the web-channel bridge can stream live child activity (each
+ /// iteration boundary, child tool call/result) into the parent
+ /// thread's UI. `None` for parent contexts that don't subscribe to
+ /// progress (e.g. CLI direct calls); the runner becomes a no-op for
+ /// child progress in that case.
+ pub on_progress: Option>,
}
tokio::task_local! {
diff --git a/src/openhuman/agent/harness/session/turn.rs b/src/openhuman/agent/harness/session/turn.rs
index 0b73aa9c0..15d9116fd 100644
--- a/src/openhuman/agent/harness/session/turn.rs
+++ b/src/openhuman/agent/harness/session/turn.rs
@@ -1072,6 +1072,7 @@ impl Agent {
tool_call_format: self.tool_dispatcher.tool_call_format(),
session_key: self.session_key.clone(),
session_parent_prefix: self.session_parent_prefix.clone(),
+ on_progress: self.on_progress.clone(),
}
}
diff --git a/src/openhuman/agent/harness/subagent_runner/ops.rs b/src/openhuman/agent/harness/subagent_runner/ops.rs
index e818ddb65..709bd7921 100644
--- a/src/openhuman/agent/harness/subagent_runner/ops.rs
+++ b/src/openhuman/agent/harness/subagent_runner/ops.rs
@@ -30,6 +30,7 @@ use super::tool_prep::{
use super::types::{SubagentMode, SubagentRunError, SubagentRunOptions, SubagentRunOutcome};
use crate::openhuman::agent::harness::definition::{AgentDefinition, PromptSource};
use crate::openhuman::agent::harness::with_current_sandbox_mode;
+use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::context::prompt::{
render_subagent_system_prompt, PromptContext, PromptTool, SubagentRenderOptions,
};
@@ -924,6 +925,11 @@ async fn run_inner_loop(
}
};
+ // Per-turn progress sink shared with the parent — `None` for runs
+ // that don't have a subscriber (CLI / triage / tests). Cloned upfront
+ // so the inner loop body doesn't repeatedly re-resolve `parent.on_progress`.
+ let progress_sink = parent.on_progress.clone();
+
for iteration in 0..max_iterations {
tracing::debug!(
task_id = %task_id,
@@ -933,6 +939,17 @@ async fn run_inner_loop(
"[subagent_runner] iteration start"
);
+ if let Some(ref tx) = progress_sink {
+ let _ = tx
+ .send(AgentProgress::SubagentIterationStarted {
+ agent_id: agent_id.to_string(),
+ task_id: task_id.to_string(),
+ iteration: (iteration + 1) as u32,
+ max_iterations: max_iterations as u32,
+ })
+ .await;
+ }
+
let resp = provider
.chat(
ChatRequest {
@@ -1027,6 +1044,18 @@ async fn run_inner_loop(
// XmlToolDispatcher's `format_results`.
let mut text_mode_result_block = String::new();
for call in &native_calls {
+ let call_started = Instant::now();
+ if let Some(ref tx) = progress_sink {
+ let _ = tx
+ .send(AgentProgress::SubagentToolCallStarted {
+ agent_id: agent_id.to_string(),
+ task_id: task_id.to_string(),
+ call_id: call.id.clone(),
+ tool_name: call.name.clone(),
+ iteration: (iteration + 1) as u32,
+ })
+ .await;
+ }
let result_text = if !allowed_names.contains(&call.name) {
tracing::warn!(
task_id = %task_id,
@@ -1118,12 +1147,12 @@ async fn run_inner_loop(
result_text
};
+ let call_success = !result_text.starts_with("Error");
+ let call_output_chars = result_text.chars().count();
+ let call_elapsed_ms = call_started.elapsed().as_millis() as u64;
+
if force_text_mode {
- let status = if result_text.starts_with("Error") {
- "error"
- } else {
- "ok"
- };
+ let status = if call_success { "ok" } else { "error" };
let _ = std::fmt::Write::write_fmt(
&mut text_mode_result_block,
format_args!(
@@ -1138,6 +1167,21 @@ async fn run_inner_loop(
});
history.push(ChatMessage::tool(tool_msg.to_string()));
}
+
+ if let Some(ref tx) = progress_sink {
+ let _ = tx
+ .send(AgentProgress::SubagentToolCallCompleted {
+ agent_id: agent_id.to_string(),
+ task_id: task_id.to_string(),
+ call_id: call.id.clone(),
+ tool_name: call.name.clone(),
+ success: call_success,
+ output_chars: call_output_chars,
+ elapsed_ms: call_elapsed_ms,
+ iteration: (iteration + 1) as u32,
+ })
+ .await;
+ }
}
if force_text_mode && !text_mode_result_block.is_empty() {
diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs
index 28cc759f9..d01f93d2e 100644
--- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs
+++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs
@@ -227,6 +227,7 @@ fn make_parent(provider: Arc, tools: Vec>) -> Parent
tool_call_format: crate::openhuman::context::prompt::ToolCallFormat::PFormat,
session_key: "0_test".into(),
session_parent_prefix: None,
+ on_progress: None,
}
}
@@ -609,3 +610,97 @@ async fn runner_errors_outside_parent_context() {
let result = run_subagent(&def, "x", SubagentRunOptions::default()).await;
assert!(matches!(result, Err(SubagentRunError::NoParentContext)));
}
+
+/// #1122 — when the parent attaches a progress sink, the inner loop
+/// emits `SubagentIterationStarted` for each round and a paired
+/// `SubagentToolCallStarted` / `SubagentToolCallCompleted` for each
+/// child tool call. The web-channel bridge translates these into the
+/// `subagent_iteration_start` / `subagent_tool_call` /
+/// `subagent_tool_result` socket events the parent thread renders.
+#[tokio::test]
+async fn typed_mode_emits_child_progress_events_when_sink_attached() {
+ use crate::openhuman::agent::progress::AgentProgress;
+
+ let provider = ScriptedProvider::new(vec![
+ tool_response("file_read", "{\"path\":\"x\"}"),
+ text_response("done"),
+ ]);
+ let mut parent = make_parent(provider, vec![stub("file_read")]);
+
+ // Wire the parent's progress sink so the runner re-emits child
+ // lifecycle events through the same channel a real session would
+ // expose to the web bridge.
+ let (tx, mut rx) = tokio::sync::mpsc::channel::(64);
+ parent.on_progress = Some(tx);
+
+ let def = make_def_named_tools(&["file_read"]);
+ let outcome = with_parent_context(parent, async {
+ run_subagent(&def, "read x", SubagentRunOptions::default()).await
+ })
+ .await
+ .expect("runner should succeed");
+ assert_eq!(outcome.iterations, 2);
+
+ // Drain everything the runner sent. The receiver's sender half is
+ // dropped when `parent` falls out of scope above, so `recv` returns
+ // None once the queue empties.
+ let mut events = Vec::new();
+ while let Some(ev) = rx.recv().await {
+ events.push(ev);
+ }
+
+ let iter_starts = events
+ .iter()
+ .filter(|e| matches!(e, AgentProgress::SubagentIterationStarted { .. }))
+ .count();
+ assert_eq!(iter_starts, 2, "one iteration_start per round");
+
+ let tool_starts: Vec<_> = events
+ .iter()
+ .filter_map(|e| match e {
+ AgentProgress::SubagentToolCallStarted {
+ call_id,
+ tool_name,
+ iteration,
+ ..
+ } => Some((call_id.clone(), tool_name.clone(), *iteration)),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(tool_starts.len(), 1);
+ assert_eq!(tool_starts[0].1, "file_read");
+ assert_eq!(tool_starts[0].2, 1);
+
+ let tool_done: Vec<_> = events
+ .iter()
+ .filter_map(|e| match e {
+ AgentProgress::SubagentToolCallCompleted {
+ call_id,
+ success,
+ iteration,
+ ..
+ } => Some((call_id.clone(), *success, *iteration)),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(tool_done.len(), 1);
+ assert_eq!(tool_done[0].0, tool_starts[0].0, "matching call_id pair");
+ assert!(tool_done[0].1, "stub tool returns ok");
+ assert_eq!(tool_done[0].2, 1);
+}
+
+/// Runs without an attached sink must remain backwards compatible — the
+/// runner is a no-op for child progress and the outcome is unchanged.
+#[tokio::test]
+async fn typed_mode_progress_emission_is_a_noop_without_sink() {
+ let provider = ScriptedProvider::new(vec![text_response("done")]);
+ let parent = make_parent(provider, vec![]);
+ assert!(parent.on_progress.is_none());
+ let def = make_def_named_tools(&[]);
+ let outcome = with_parent_context(parent, async {
+ run_subagent(&def, "x", SubagentRunOptions::default()).await
+ })
+ .await
+ .expect("runner should succeed");
+ assert_eq!(outcome.iterations, 1);
+}
diff --git a/src/openhuman/agent/progress.rs b/src/openhuman/agent/progress.rs
index 1e691b794..362fd363c 100644
--- a/src/openhuman/agent/progress.rs
+++ b/src/openhuman/agent/progress.rs
@@ -53,13 +53,35 @@ pub enum AgentProgress {
},
/// A sub-agent was spawned during tool execution.
- SubagentSpawned { agent_id: String, task_id: String },
+ SubagentSpawned {
+ agent_id: String,
+ task_id: String,
+ /// Resolved spawn mode — `"typed"` or `"fork"`. The UI uses this
+ /// to distinguish narrow-prompt delegations from prefix-replay
+ /// forks when labelling the live subagent block.
+ mode: String,
+ /// `true` when the spawn was requested with
+ /// `dedicated_thread: true`. The UI links the inline subagent
+ /// row to the eventual worker thread once the run completes.
+ dedicated_thread: bool,
+ /// Character length of the delegated prompt — useful to decide
+ /// whether to render the prompt detail inline or behind a
+ /// "show more" affordance.
+ prompt_chars: usize,
+ },
/// A sub-agent completed successfully.
SubagentCompleted {
agent_id: String,
task_id: String,
elapsed_ms: u64,
+ /// Number of LLM iterations the sub-agent actually used. The
+ /// UI surfaces this in the parent thread's subagent row so a
+ /// completed delegation reads as "researcher · 3 turns · 4.2s"
+ /// instead of just "done".
+ iterations: u32,
+ /// Character length of the sub-agent's final assistant text.
+ output_chars: usize,
},
/// A sub-agent failed.
@@ -69,6 +91,47 @@ pub enum AgentProgress {
error: String,
},
+ /// A sub-agent's inner LLM iteration is starting. Emitted **only
+ /// from inside [`crate::openhuman::agent::harness::subagent_runner`]**
+ /// when the parent context carries an `on_progress` sink — the
+ /// outer parent loop uses [`Self::IterationStarted`] for its own
+ /// rounds. Carries the child's `task_id` so the UI can attribute
+ /// the round to a specific live subagent row.
+ SubagentIterationStarted {
+ agent_id: String,
+ task_id: String,
+ /// 1-based child iteration index.
+ iteration: u32,
+ /// Maximum iterations configured for this child run.
+ max_iterations: u32,
+ },
+
+ /// A sub-agent is about to execute a tool. Distinct from
+ /// [`Self::ToolCallStarted`] so the parent thread can render
+ /// child-tool activity nested under the subagent row instead of
+ /// flattened into the parent's tool timeline.
+ SubagentToolCallStarted {
+ agent_id: String,
+ task_id: String,
+ call_id: String,
+ tool_name: String,
+ /// 1-based child iteration index this call belongs to.
+ iteration: u32,
+ },
+
+ /// A sub-agent's tool execution finished.
+ SubagentToolCallCompleted {
+ agent_id: String,
+ task_id: String,
+ call_id: String,
+ tool_name: String,
+ success: bool,
+ output_chars: usize,
+ elapsed_ms: u64,
+ /// 1-based child iteration index.
+ iteration: u32,
+ },
+
/// A chunk of visible assistant text arrived from the provider
/// while the current iteration is still in flight.
TextDelta {
diff --git a/src/openhuman/agent/triage/escalation.rs b/src/openhuman/agent/triage/escalation.rs
index 466f7eb81..c25d20a25 100644
--- a/src/openhuman/agent/triage/escalation.rs
+++ b/src/openhuman/agent/triage/escalation.rs
@@ -172,6 +172,10 @@ async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result Result