detached 3e0f68b

feat(subagents): expose live trace observations

commit
3e0f68b771254564adcecc9f63f257470388aecf
author
brice · · 2026-08-12 10:00 UTC
committer
brice · · 2026-08-12 12:33 UTC
parent
d6b93bc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +378 -54
M docs/subagents.md +2 -2
@@ -137,7 +137,7 @@ Shared mode preserves the historical host working directory, so workers there mu
137137
138138 Every child retains narrowly scoped internal process control and durable task/result metadata. These details coordinate startup, cancellation, and recovery; they are not a user-facing progress model. Diagnostic progress comes only from paired trace boundaries and their projection into open operations, terminal facts, or an explicitly insufficient observation.
139139
140-During a trace-observed open operation, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), the open operation, and its elapsed duration. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted operations collapse into one count summary so the editor remains visible. A process lifecycle, heartbeat, or generic `working` label never creates a row; use `/subagents` for the current factual operation or last observed fact.
140+During a trace-observed open operation, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), the most specific open operation, and its elapsed duration. A recent safe observation is shown alongside it when available, for example `assistant streaming · 1s ago`; normal mode never displays message text, reasoning text, tool arguments, or tool output. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted operations collapse into one count summary so the editor remains visible. A process lifecycle, heartbeat, or generic `working` label never creates a row; use `/subagents` for the current factual operation or last observed fact.
141141
142142 `/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript.
143143
@@ -160,7 +160,7 @@ A subagent timeout applies to each active delegated turn. When the deadline expi
160160
161161 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
162162
163-When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use the shared trace projection and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, open operation and its start time when one is observed, a terminal fact or last observed event, `lifetime_turns` and `current_run_turns`, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It deliberately omits generic lifecycle claims such as `working`, `alive`, `idle`, `process_state`, and `turn_state`, as well as prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.
163+When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use the shared trace projection and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, open operation and its start time when one is observed, the derived `primary_operation`, the last safe live observation and timestamp, a terminal fact or last event, `lifetime_turns` and `current_run_turns`, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result availability/reference/delivery metadata when available. A result is only marked delivered after the parent accepts its required-work notification. It deliberately omits generic lifecycle claims such as `working`, `alive`, `idle`, `process_state`, and `turn_state`, as well as prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.
164164
165165 ## Local execution trace
166166
@@ -137,7 +137,7 @@ Shared mode preserves the historical host working directory, so workers there mu
137 137
138 Every child retains narrowly scoped internal process control and durable task/result metadata. These details coordinate startup, cancellation, and recovery; they are not a user-facing progress model. Diagnostic progress comes only from paired trace boundaries and their projection into open operations, terminal facts, or an explicitly insufficient observation.138 Every child retains narrowly scoped internal process control and durable task/result metadata. These details coordinate startup, cancellation, and recovery; they are not a user-facing progress model. Diagnostic progress comes only from paired trace boundaries and their projection into open operations, terminal facts, or an explicitly insufficient observation.
139 139
140-During a trace-observed open operation, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), the open operation, and its elapsed duration. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted operations collapse into one count summary so the editor remains visible. A process lifecycle, heartbeat, or generic `working` label never creates a row; use `/subagents` for the current factual operation or last observed fact.140+During a trace-observed open operation, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), the most specific open operation, and its elapsed duration. A recent safe observation is shown alongside it when available, for example `assistant streaming · 1s ago`; normal mode never displays message text, reasoning text, tool arguments, or tool output. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted operations collapse into one count summary so the editor remains visible. A process lifecycle, heartbeat, or generic `working` label never creates a row; use `/subagents` for the current factual operation or last observed fact.
141 141
142 `/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript.142 `/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript.
143 143
@@ -160,7 +160,7 @@ A subagent timeout applies to each active delegated turn. When the deadline expi
160 160
161 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.161 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
162 162
163-When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use the shared trace projection and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, open operation and its start time when one is observed, a terminal fact or last observed event, `lifetime_turns` and `current_run_turns`, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It deliberately omits generic lifecycle claims such as `working`, `alive`, `idle`, `process_state`, and `turn_state`, as well as prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.163+When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use the shared trace projection and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, open operation and its start time when one is observed, the derived `primary_operation`, the last safe live observation and timestamp, a terminal fact or last event, `lifetime_turns` and `current_run_turns`, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result availability/reference/delivery metadata when available. A result is only marked delivered after the parent accepts its required-work notification. It deliberately omits generic lifecycle claims such as `working`, `alive`, `idle`, `process_state`, and `turn_state`, as well as prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.
164 164
165 ## Local execution trace165 ## Local execution trace
166 166
M packages/agent/modes/subagent_indicator.go +12 -4
@@ -17,10 +17,14 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []
1717 lines := make([]string, 0, len(snapshots))
1818 for _, snapshot := range snapshots {
1919 view := views[snapshot.ID]
20- if len(view.OpenOperations) == 0 {
20+ operation := view.PrimaryOperation
21+ if operation == nil && len(view.OpenOperations) != 0 {
22+ operation = &view.OpenOperations[0]
23+ }
24+ if operation == nil {
2125 continue
2226 }
23- line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, view.OpenOperations[0], width, now)
27+ line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, *operation, view.LastObservation, width, now)
2428 if line != "" {
2529 lines = append(lines, line)
2630 }
@@ -28,7 +32,7 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []
2832 return lines
2933 }
3034
31-func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, operation subagents.Operation, width int, now time.Time) string {
35+func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, operation subagents.Operation, observation *subagents.LiveObservation, width int, now time.Time) string {
3236 name := sanitizeSubagentIndicatorText(snapshot.Subagent)
3337 if name == "" {
3438 name = sanitizeSubagentIndicatorText(snapshot.ID)
@@ -41,6 +45,9 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
4145 spinnerGlyph = "."
4246 }
4347 activity := operation.Label()
48+ if observation != nil {
49+ activity = observation.Label() + " · " + formatSubagentActivityAge(observation.At, now) + " ago"
50+ }
4451 age := formatSubagentActivityAge(operation.StartedAt, now)
4552 plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)
4653 if plain == "" {
@@ -167,7 +174,8 @@ func (i *Interactive) activeSubagentActivitySnapshots() ([]subagents.AgentSnapsh
167174 if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession {
168175 continue
169176 }
170- if len(views[agent.ID].OpenOperations) == 0 {
177+ view := views[agent.ID]
178+ if view.PrimaryOperation == nil && len(view.OpenOperations) == 0 {
171179 continue
172180 }
173181 out = append(out, subagents.AgentSnapshot{ID: agent.ID, Started: agent.Started, Subagent: agent.Subagent})
@@ -17,10 +17,14 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []
17 lines := make([]string, 0, len(snapshots))17 lines := make([]string, 0, len(snapshots))
18 for _, snapshot := range snapshots {18 for _, snapshot := range snapshots {
19 view := views[snapshot.ID]19 view := views[snapshot.ID]
20- if len(view.OpenOperations) == 0 {20+ operation := view.PrimaryOperation
21+ if operation == nil && len(view.OpenOperations) != 0 {
22+ operation = &view.OpenOperations[0]
23+ }
24+ if operation == nil {
21 continue25 continue
22 }26 }
23- line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, view.OpenOperations[0], width, now)27+ line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, *operation, view.LastObservation, width, now)
24 if line != "" {28 if line != "" {
25 lines = append(lines, line)29 lines = append(lines, line)
26 }30 }
@@ -28,7 +32,7 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []
28 return lines32 return lines
29 }33 }
30 34
31-func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, operation subagents.Operation, width int, now time.Time) string {35+func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, operation subagents.Operation, observation *subagents.LiveObservation, width int, now time.Time) string {
32 name := sanitizeSubagentIndicatorText(snapshot.Subagent)36 name := sanitizeSubagentIndicatorText(snapshot.Subagent)
33 if name == "" {37 if name == "" {
34 name = sanitizeSubagentIndicatorText(snapshot.ID)38 name = sanitizeSubagentIndicatorText(snapshot.ID)
@@ -41,6 +45,9 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
41 spinnerGlyph = "."45 spinnerGlyph = "."
42 }46 }
43 activity := operation.Label()47 activity := operation.Label()
48+ if observation != nil {
49+ activity = observation.Label() + " · " + formatSubagentActivityAge(observation.At, now) + " ago"
50+ }
44 age := formatSubagentActivityAge(operation.StartedAt, now)51 age := formatSubagentActivityAge(operation.StartedAt, now)
45 plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)52 plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)
46 if plain == "" {53 if plain == "" {
@@ -167,7 +174,8 @@ func (i *Interactive) activeSubagentActivitySnapshots() ([]subagents.AgentSnapsh
167 if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession {174 if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession {
168 continue175 continue
169 }176 }
170- if len(views[agent.ID].OpenOperations) == 0 {177+ view := views[agent.ID]
178+ if view.PrimaryOperation == nil && len(view.OpenOperations) == 0 {
171 continue179 continue
172 }180 }
173 out = append(out, subagents.AgentSnapshot{ID: agent.ID, Started: agent.Started, Subagent: agent.Subagent})181 out = append(out, subagents.AgentSnapshot{ID: agent.ID, Started: agent.Started, Subagent: agent.Subagent})
M packages/agent/modes/subagent_indicator_test.go +10 -0
@@ -27,6 +27,16 @@ func TestRenderSubagentActivityLinesShowsOnlyTraceOpenOperations(t *testing.T) {
2727 }
2828 }
2929
30+func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) {
31+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
32+ lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
33+ "review-123": {PrimaryOperation: &subagents.Operation{Type: "turn.started", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", At: now.Add(-time.Second)}},
34+ }, 100, now))
35+ if got := strings.Join(lines, "\n"); !strings.Contains(got, "assistant streaming · 1s ago") || strings.Contains(got, "delta") {
36+ t.Fatalf("activity lines = %#v", lines)
37+ }
38+}
39+
3040 func TestRenderSubagentActivityLinesKeepsNameAndAgeOnNarrowTerminals(t *testing.T) {
3141 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
3242 lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
@@ -27,6 +27,16 @@ func TestRenderSubagentActivityLinesShowsOnlyTraceOpenOperations(t *testing.T) {
27 }27 }
28 }28 }
29 29
30+func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) {
31+ now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
32+ lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
33+ "review-123": {PrimaryOperation: &subagents.Operation{Type: "turn.started", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", At: now.Add(-time.Second)}},
34+ }, 100, now))
35+ if got := strings.Join(lines, "\n"); !strings.Contains(got, "assistant streaming · 1s ago") || strings.Contains(got, "delta") {
36+ t.Fatalf("activity lines = %#v", lines)
37+ }
38+}
39+
30 func TestRenderSubagentActivityLinesKeepsNameAndAgeOnNarrowTerminals(t *testing.T) {40 func TestRenderSubagentActivityLinesKeepsNameAndAgeOnNarrowTerminals(t *testing.T) {
31 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)41 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
32 lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{42 lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
M packages/agent/modes/subagents_dialog.go +11 -18
@@ -1457,17 +1457,7 @@ func (d *subagentsDialog) agentHasOpenOperation(id string) bool {
14571457 }
14581458
14591459 func (d *subagentsDialog) traceSummary(id string) string {
1460- view := d.traceView(id)
1461- if view.Terminal != "" {
1462- return view.Terminal
1463- }
1464- if len(view.OpenOperations) != 0 {
1465- return view.OpenOperations[0].Label()
1466- }
1467- if view.LastEvent.Type != "" {
1468- return "last event " + view.LastEvent.Type
1469- }
1470- return "no observable operation"
1460+ return d.traceView(id).Summary()
14711461 }
14721462
14731463 // renderSupervisorTranscriptBlocks converts the agent's flat transcript
@@ -1634,14 +1624,17 @@ func (d *subagentsDialog) traceView(id string) subagents.AgentTraceView {
16341624 // tool open 3m fix-login-12345 3m inspect main.go
16351625 // completed write-tests-67890 1h add coverage
16361626 func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string {
1637- fact := "no observable operation"
1638- if view.Terminal != "" {
1639- fact = view.Terminal
1640- } else if len(view.OpenOperations) != 0 {
1641- operation := view.OpenOperations[0]
1642- fact = operation.Label() + " " + formatAge(operation.StartedAt)
1627+ fact := view.Summary()
1628+ primary := view.PrimaryOperation
1629+ if primary == nil && len(view.OpenOperations) != 0 {
1630+ primary = &view.OpenOperations[0]
1631+ }
1632+ if primary != nil {
1633+ fact += " " + formatAge(primary.StartedAt)
1634+ } else if view.LastObservation != nil {
1635+ fact += " " + formatAge(view.LastObservation.At)
16431636 } else if view.LastEvent.Type != "" {
1644- fact = "last " + view.LastEvent.Type + " " + formatAge(view.LastEvent.Timestamp)
1637+ fact += " " + formatAge(view.LastEvent.Timestamp)
16451638 }
16461639 age := formatAge(r.Started)
16471640 left := fmt.Sprintf("%-30s %-26s %-8s ", truncateLineSafe(fact, 30), truncateLineSafe(r.ID, 26), age)
@@ -1457,17 +1457,7 @@ func (d *subagentsDialog) agentHasOpenOperation(id string) bool {
1457 }1457 }
1458 1458
1459 func (d *subagentsDialog) traceSummary(id string) string {1459 func (d *subagentsDialog) traceSummary(id string) string {
1460- view := d.traceView(id)1460+ return d.traceView(id).Summary()
1461- if view.Terminal != "" {
1462- return view.Terminal
1463- }
1464- if len(view.OpenOperations) != 0 {
1465- return view.OpenOperations[0].Label()
1466- }
1467- if view.LastEvent.Type != "" {
1468- return "last event " + view.LastEvent.Type
1469- }
1470- return "no observable operation"
1471 }1461 }
1472 1462
1473 // renderSupervisorTranscriptBlocks converts the agent's flat transcript1463 // renderSupervisorTranscriptBlocks converts the agent's flat transcript
@@ -1634,14 +1624,17 @@ func (d *subagentsDialog) traceView(id string) subagents.AgentTraceView {
1634 // tool open 3m fix-login-12345 3m inspect main.go1624 // tool open 3m fix-login-12345 3m inspect main.go
1635 // completed write-tests-67890 1h add coverage1625 // completed write-tests-67890 1h add coverage
1636 func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string {1626 func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string {
1637- fact := "no observable operation"1627+ fact := view.Summary()
1638- if view.Terminal != "" {1628+ primary := view.PrimaryOperation
1639- fact = view.Terminal1629+ if primary == nil && len(view.OpenOperations) != 0 {
1640- } else if len(view.OpenOperations) != 0 {1630+ primary = &view.OpenOperations[0]
1641- operation := view.OpenOperations[0]1631+ }
1642- fact = operation.Label() + " " + formatAge(operation.StartedAt)1632+ if primary != nil {
1633+ fact += " " + formatAge(primary.StartedAt)
1634+ } else if view.LastObservation != nil {
1635+ fact += " " + formatAge(view.LastObservation.At)
1643 } else if view.LastEvent.Type != "" {1636 } else if view.LastEvent.Type != "" {
1644- fact = "last " + view.LastEvent.Type + " " + formatAge(view.LastEvent.Timestamp)1637+ fact += " " + formatAge(view.LastEvent.Timestamp)
1645 }1638 }
1646 age := formatAge(r.Started)1639 age := formatAge(r.Started)
1647 left := fmt.Sprintf("%-30s %-26s %-8s ", truncateLineSafe(fact, 30), truncateLineSafe(r.ID, 26), age)1640 left := fmt.Sprintf("%-30s %-26s %-8s ", truncateLineSafe(fact, 30), truncateLineSafe(r.ID, 26), age)
M packages/agent/modes/subagents_dialog_test.go +11 -0
@@ -24,6 +24,17 @@ func staticSnapshots(rows ...subagents.AgentSnapshot) func() []subagents.AgentSn
2424 }
2525 }
2626
27+func TestFormatSupervisorRowShowsTerminalResultDelivery(t *testing.T) {
28+ at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
29+ got := formatSupervisorRow(subagents.AgentSnapshot{ID: "review-1", Started: at}, subagents.AgentTraceView{
30+ Terminal: "completed",
31+ Result: &subagents.ResultFact{Available: true, Delivered: true, At: at},
32+ }, 120)
33+ if !strings.Contains(got, "completed · result delivered") {
34+ t.Fatalf("row = %q", got)
35+ }
36+}
37+
2738 func TestSubagentsDialogEmptyState(t *testing.T) {
2839 d := newSubagentsDialog()
2940 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
@@ -24,6 +24,17 @@ func staticSnapshots(rows ...subagents.AgentSnapshot) func() []subagents.AgentSn
24 }24 }
25 }25 }
26 26
27+func TestFormatSupervisorRowShowsTerminalResultDelivery(t *testing.T) {
28+ at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
29+ got := formatSupervisorRow(subagents.AgentSnapshot{ID: "review-1", Started: at}, subagents.AgentTraceView{
30+ Terminal: "completed",
31+ Result: &subagents.ResultFact{Available: true, Delivered: true, At: at},
32+ }, 120)
33+ if !strings.Contains(got, "completed · result delivered") {
34+ t.Fatalf("row = %q", got)
35+ }
36+}
37+
27 func TestSubagentsDialogEmptyState(t *testing.T) {38 func TestSubagentsDialogEmptyState(t *testing.T) {
28 d := newSubagentsDialog()39 d := newSubagentsDialog()
29 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")40 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
M packages/agent/subagents/projection.go +166 -7
@@ -14,6 +14,7 @@ type Operation struct {
1414 AgentID string
1515 TurnID string
1616 CallID string
17+ Name string
1718 StartedAt time.Time
1819 }
1920
@@ -30,20 +31,106 @@ func (o Operation) Duration(now time.Time) time.Duration {
3031 }
3132
3233 // Label returns the shared concise label for an open operation.
33-func (o Operation) Label() string { return strings.TrimSuffix(o.Type, ".started") + " open" }
34+func (o Operation) Label() string {
35+ label := strings.TrimSuffix(o.Type, ".started")
36+ if o.Name != "" {
37+ label += " " + o.Name
38+ }
39+ return label + " open"
40+}
41+
42+// LiveObservation is the last safe, factual signal received from a worker.
43+// It intentionally contains no worker text, tool arguments, or output.
44+type LiveObservation struct {
45+ Type string
46+ Source string
47+ At time.Time
48+ TurnID string
49+ CallID string
50+ Name string
51+}
52+
53+// Label returns a concise safe description of this observation.
54+func (o LiveObservation) Label() string {
55+ switch o.Type {
56+ case "assistant.stream.observed":
57+ return "assistant streaming"
58+ case "reasoning.stream.observed":
59+ return "reasoning stream observed"
60+ case "tool.output.observed":
61+ if o.Name != "" {
62+ return "tool output observed " + o.Name
63+ }
64+ return "tool output observed"
65+ case "worker.protocol.observed":
66+ if o.Source != "" {
67+ return "worker " + o.Source
68+ }
69+ return "worker protocol event"
70+ default:
71+ return strings.ReplaceAll(o.Type, ".", " ")
72+ }
73+}
74+
75+// ResultFact describes a durable result and whether the parent accepted its
76+// required-work notification. Available is not equivalent to Delivered.
77+type ResultFact struct {
78+ Available bool
79+ Ref string
80+ Delivered bool
81+ Failed bool
82+ At time.Time
83+}
3484
3585 // AgentTraceView is the only user-facing execution state. It intentionally
3686 // contains operations and terminal facts rather than lifecycle guesses such as
3787 // working, idle, or alive.
3888 type AgentTraceView struct {
39- AgentID string
40- LastEvent TraceEvent
41- OpenOperations []Operation
42- Terminal string
89+ AgentID string
90+ LastEvent TraceEvent
91+ LastObservation *LiveObservation
92+ OpenOperations []Operation
93+ PrimaryOperation *Operation
94+ Terminal string
95+ Result *ResultFact
96+}
97+
98+// Summary returns the most specific factual activity or terminal result fact.
99+func (v AgentTraceView) Summary() string {
100+ if v.Terminal != "" {
101+ if v.Result != nil && v.Result.Available {
102+ if v.Result.Delivered {
103+ return v.Terminal + " · result delivered"
104+ }
105+ if v.Result.Failed {
106+ return v.Terminal + " · result delivery failed"
107+ }
108+ return v.Terminal + " · result available"
109+ }
110+ return v.Terminal
111+ }
112+ primary := v.PrimaryOperation
113+ if primary == nil && len(v.OpenOperations) != 0 {
114+ primary = &v.OpenOperations[0]
115+ }
116+ if primary != nil {
117+ if v.LastObservation != nil {
118+ return primary.Label() + " · " + v.LastObservation.Label()
119+ }
120+ return primary.Label()
121+ }
122+ if v.LastObservation != nil {
123+ return v.LastObservation.Label()
124+ }
125+ if v.LastEvent.Type != "" {
126+ return "last event " + v.LastEvent.Type
127+ }
128+ return "no observable operation"
43129 }
44130
45131 // ProjectTrace reduces a chronologically ordered trace into per-agent facts.
46-// Unknown events are retained as LastEvent but never guessed into an operation.
132+// Unknown worker protocol events remain factual observations and are never
133+// guessed into operations.
47134 func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
48135 views := make(map[string]AgentTraceView)
49136 open := make(map[string]map[string]Operation)
@@ -60,11 +147,18 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
60147 open[event.AgentID] = make(map[string]Operation)
61148 }
62149 callID, _ := event.Data["call_id"].(string)
63- open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, StartedAt: event.Timestamp}
150+ name, _ := event.Data["name"].(string)
151+ open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, Name: name, StartedAt: event.Timestamp}
64152 }
65153 if ends {
66154 delete(open[event.AgentID], key)
67155 }
156+ if observation := traceObservation(event); observation != nil {
157+ view.LastObservation = observation
158+ }
159+ if result := traceResultFact(event, view.Result); result != nil {
160+ view.Result = result
161+ }
68162 if terminal != "" {
69163 view.Terminal = terminal
70164 delete(open, event.AgentID)
@@ -78,6 +172,9 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
78172 }
79173 sort.Slice(view.OpenOperations, func(i, j int) bool {
80174 left, right := view.OpenOperations[i], view.OpenOperations[j]
175+ if operationPriority(left) != operationPriority(right) {
176+ return operationPriority(left) < operationPriority(right)
177+ }
81178 if !left.StartedAt.Equal(right.StartedAt) {
82179 return left.StartedAt.Before(right.StartedAt)
83180 }
@@ -89,11 +186,65 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
89186 }
90187 return left.CallID < right.CallID
91188 })
189+ if len(view.OpenOperations) != 0 {
190+ primary := view.OpenOperations[0]
191+ view.PrimaryOperation = &primary
192+ }
92193 views[agentID] = view
93194 }
94195 return views
95196 }
96197
198+func operationPriority(o Operation) int {
199+ switch o.Type {
200+ case "tool.started":
201+ return 0
202+ case "provider.request.started":
203+ return 1
204+ case "agent.wait.started":
205+ return 2
206+ default:
207+ return 3
208+ }
209+}
210+
211+func traceObservation(event TraceEvent) *LiveObservation {
212+ typeName := event.Type
213+ source, _ := event.Data["source_event"].(string)
214+ if source == "" {
215+ source, _ = event.Data["worker_event"].(string)
216+ }
217+ name, _ := event.Data["name"].(string)
218+ switch typeName {
219+ case "assistant.stream.observed", "reasoning.stream.observed", "tool.output.observed", "worker.protocol.observed":
220+ return &LiveObservation{Type: typeName, Source: source, At: event.Timestamp, TurnID: event.TurnID, Name: name}
221+ }
222+ return nil
223+}
224+
225+func traceResultFact(event TraceEvent, previous *ResultFact) *ResultFact {
226+ if event.Type != "result.available" && event.Type != "result.delivered" && event.Type != "result.delivery.failed" {
227+ return previous
228+ }
229+ result := ResultFact{}
230+ if previous != nil {
231+ result = *previous
232+ }
233+ if ref, _ := event.Data["ref"].(string); ref != "" {
234+ result.Ref = ref
235+ }
236+ result.At = event.Timestamp
237+ switch event.Type {
238+ case "result.available":
239+ result.Available = true
240+ case "result.delivered":
241+ result.Available, result.Delivered, result.Failed = true, true, false
242+ case "result.delivery.failed":
243+ result.Available, result.Failed = true, true
244+ }
245+ return &result
246+}
247+
97248 func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) {
98249 callID, _ := event.Data["call_id"].(string)
99250 key = event.Type + ":" + event.TurnID + ":" + callID
@@ -106,6 +257,14 @@ func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal st
106257 return "tool:" + event.TurnID + ":" + callID, true, false, ""
107258 case "tool.finished", "tool.failed", "tool.cancelled":
108259 return "tool:" + event.TurnID + ":" + callID, false, true, ""
260+ case "provider.request.started":
261+ return "provider:" + event.TurnID, true, false, ""
262+ case "provider.request.finished", "provider.request.failed", "provider.request.cancelled":
263+ return "provider:" + event.TurnID, false, true, ""
264+ case "agent.wait.started":
265+ return "wait:" + event.TurnID, true, false, ""
266+ case "agent.wait.finished":
267+ return "wait:" + event.TurnID, false, true, ""
109268 case "agent.finished":
110269 return key, false, false, "completed"
111270 case "agent.failed":
@@ -14,6 +14,7 @@ type Operation struct {
14 AgentID string14 AgentID string
15 TurnID string15 TurnID string
16 CallID string16 CallID string
17+ Name string
17 StartedAt time.Time18 StartedAt time.Time
18 }19 }
19 20
@@ -30,20 +31,106 @@ func (o Operation) Duration(now time.Time) time.Duration {
30 }31 }
31 32
32 // Label returns the shared concise label for an open operation.33 // Label returns the shared concise label for an open operation.
33-func (o Operation) Label() string { return strings.TrimSuffix(o.Type, ".started") + " open" }34+func (o Operation) Label() string {
35+ label := strings.TrimSuffix(o.Type, ".started")
36+ if o.Name != "" {
37+ label += " " + o.Name
38+ }
39+ return label + " open"
40+}
41+
42+// LiveObservation is the last safe, factual signal received from a worker.
43+// It intentionally contains no worker text, tool arguments, or output.
44+type LiveObservation struct {
45+ Type string
46+ Source string
47+ At time.Time
48+ TurnID string
49+ CallID string
50+ Name string
51+}
52+
53+// Label returns a concise safe description of this observation.
54+func (o LiveObservation) Label() string {
55+ switch o.Type {
56+ case "assistant.stream.observed":
57+ return "assistant streaming"
58+ case "reasoning.stream.observed":
59+ return "reasoning stream observed"
60+ case "tool.output.observed":
61+ if o.Name != "" {
62+ return "tool output observed " + o.Name
63+ }
64+ return "tool output observed"
65+ case "worker.protocol.observed":
66+ if o.Source != "" {
67+ return "worker " + o.Source
68+ }
69+ return "worker protocol event"
70+ default:
71+ return strings.ReplaceAll(o.Type, ".", " ")
72+ }
73+}
74+
75+// ResultFact describes a durable result and whether the parent accepted its
76+// required-work notification. Available is not equivalent to Delivered.
77+type ResultFact struct {
78+ Available bool
79+ Ref string
80+ Delivered bool
81+ Failed bool
82+ At time.Time
83+}
34 84
35 // AgentTraceView is the only user-facing execution state. It intentionally85 // AgentTraceView is the only user-facing execution state. It intentionally
36 // contains operations and terminal facts rather than lifecycle guesses such as86 // contains operations and terminal facts rather than lifecycle guesses such as
37 // working, idle, or alive.87 // working, idle, or alive.
38 type AgentTraceView struct {88 type AgentTraceView struct {
39- AgentID string89+ AgentID string
40- LastEvent TraceEvent90+ LastEvent TraceEvent
41- OpenOperations []Operation91+ LastObservation *LiveObservation
42- Terminal string92+ OpenOperations []Operation
93+ PrimaryOperation *Operation
94+ Terminal string
95+ Result *ResultFact
96+}
97+
98+// Summary returns the most specific factual activity or terminal result fact.
99+func (v AgentTraceView) Summary() string {
100+ if v.Terminal != "" {
101+ if v.Result != nil && v.Result.Available {
102+ if v.Result.Delivered {
103+ return v.Terminal + " · result delivered"
104+ }
105+ if v.Result.Failed {
106+ return v.Terminal + " · result delivery failed"
107+ }
108+ return v.Terminal + " · result available"
109+ }
110+ return v.Terminal
111+ }
112+ primary := v.PrimaryOperation
113+ if primary == nil && len(v.OpenOperations) != 0 {
114+ primary = &v.OpenOperations[0]
115+ }
116+ if primary != nil {
117+ if v.LastObservation != nil {
118+ return primary.Label() + " · " + v.LastObservation.Label()
119+ }
120+ return primary.Label()
121+ }
122+ if v.LastObservation != nil {
123+ return v.LastObservation.Label()
124+ }
125+ if v.LastEvent.Type != "" {
126+ return "last event " + v.LastEvent.Type
127+ }
128+ return "no observable operation"
43 }129 }
44 130
45 // ProjectTrace reduces a chronologically ordered trace into per-agent facts.131 // ProjectTrace reduces a chronologically ordered trace into per-agent facts.
46-// Unknown events are retained as LastEvent but never guessed into an operation.132+// Unknown worker protocol events remain factual observations and are never
133+// guessed into operations.
47 func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {134 func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
48 views := make(map[string]AgentTraceView)135 views := make(map[string]AgentTraceView)
49 open := make(map[string]map[string]Operation)136 open := make(map[string]map[string]Operation)
@@ -60,11 +147,18 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
60 open[event.AgentID] = make(map[string]Operation)147 open[event.AgentID] = make(map[string]Operation)
61 }148 }
62 callID, _ := event.Data["call_id"].(string)149 callID, _ := event.Data["call_id"].(string)
63- open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, StartedAt: event.Timestamp}150+ name, _ := event.Data["name"].(string)
151+ open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, Name: name, StartedAt: event.Timestamp}
64 }152 }
65 if ends {153 if ends {
66 delete(open[event.AgentID], key)154 delete(open[event.AgentID], key)
67 }155 }
156+ if observation := traceObservation(event); observation != nil {
157+ view.LastObservation = observation
158+ }
159+ if result := traceResultFact(event, view.Result); result != nil {
160+ view.Result = result
161+ }
68 if terminal != "" {162 if terminal != "" {
69 view.Terminal = terminal163 view.Terminal = terminal
70 delete(open, event.AgentID)164 delete(open, event.AgentID)
@@ -78,6 +172,9 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
78 }172 }
79 sort.Slice(view.OpenOperations, func(i, j int) bool {173 sort.Slice(view.OpenOperations, func(i, j int) bool {
80 left, right := view.OpenOperations[i], view.OpenOperations[j]174 left, right := view.OpenOperations[i], view.OpenOperations[j]
175+ if operationPriority(left) != operationPriority(right) {
176+ return operationPriority(left) < operationPriority(right)
177+ }
81 if !left.StartedAt.Equal(right.StartedAt) {178 if !left.StartedAt.Equal(right.StartedAt) {
82 return left.StartedAt.Before(right.StartedAt)179 return left.StartedAt.Before(right.StartedAt)
83 }180 }
@@ -89,11 +186,65 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
89 }186 }
90 return left.CallID < right.CallID187 return left.CallID < right.CallID
91 })188 })
189+ if len(view.OpenOperations) != 0 {
190+ primary := view.OpenOperations[0]
191+ view.PrimaryOperation = &primary
192+ }
92 views[agentID] = view193 views[agentID] = view
93 }194 }
94 return views195 return views
95 }196 }
96 197
198+func operationPriority(o Operation) int {
199+ switch o.Type {
200+ case "tool.started":
201+ return 0
202+ case "provider.request.started":
203+ return 1
204+ case "agent.wait.started":
205+ return 2
206+ default:
207+ return 3
208+ }
209+}
210+
211+func traceObservation(event TraceEvent) *LiveObservation {
212+ typeName := event.Type
213+ source, _ := event.Data["source_event"].(string)
214+ if source == "" {
215+ source, _ = event.Data["worker_event"].(string)
216+ }
217+ name, _ := event.Data["name"].(string)
218+ switch typeName {
219+ case "assistant.stream.observed", "reasoning.stream.observed", "tool.output.observed", "worker.protocol.observed":
220+ return &LiveObservation{Type: typeName, Source: source, At: event.Timestamp, TurnID: event.TurnID, Name: name}
221+ }
222+ return nil
223+}
224+
225+func traceResultFact(event TraceEvent, previous *ResultFact) *ResultFact {
226+ if event.Type != "result.available" && event.Type != "result.delivered" && event.Type != "result.delivery.failed" {
227+ return previous
228+ }
229+ result := ResultFact{}
230+ if previous != nil {
231+ result = *previous
232+ }
233+ if ref, _ := event.Data["ref"].(string); ref != "" {
234+ result.Ref = ref
235+ }
236+ result.At = event.Timestamp
237+ switch event.Type {
238+ case "result.available":
239+ result.Available = true
240+ case "result.delivered":
241+ result.Available, result.Delivered, result.Failed = true, true, false
242+ case "result.delivery.failed":
243+ result.Available, result.Failed = true, true
244+ }
245+ return &result
246+}
247+
97 func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) {248 func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) {
98 callID, _ := event.Data["call_id"].(string)249 callID, _ := event.Data["call_id"].(string)
99 key = event.Type + ":" + event.TurnID + ":" + callID250 key = event.Type + ":" + event.TurnID + ":" + callID
@@ -106,6 +257,14 @@ func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal st
106 return "tool:" + event.TurnID + ":" + callID, true, false, ""257 return "tool:" + event.TurnID + ":" + callID, true, false, ""
107 case "tool.finished", "tool.failed", "tool.cancelled":258 case "tool.finished", "tool.failed", "tool.cancelled":
108 return "tool:" + event.TurnID + ":" + callID, false, true, ""259 return "tool:" + event.TurnID + ":" + callID, false, true, ""
260+ case "provider.request.started":
261+ return "provider:" + event.TurnID, true, false, ""
262+ case "provider.request.finished", "provider.request.failed", "provider.request.cancelled":
263+ return "provider:" + event.TurnID, false, true, ""
264+ case "agent.wait.started":
265+ return "wait:" + event.TurnID, true, false, ""
266+ case "agent.wait.finished":
267+ return "wait:" + event.TurnID, false, true, ""
109 case "agent.finished":268 case "agent.finished":
110 return key, false, false, "completed"269 return key, false, false, "completed"
111 case "agent.failed":270 case "agent.failed":
M packages/agent/subagents/projection_test.go +23 -0
@@ -56,6 +56,29 @@ func TestProjectTraceClosesFailedToolByCallID(t *testing.T) {
5656 }
5757 }
5858
59+func TestProjectTraceSelectsSpecificActivityAndProjectsResultDelivery(t *testing.T) {
60+ started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
61+ view := ProjectTrace([]TraceEvent{
62+ {Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
63+ {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one", "name": "bash"}},
64+ {Timestamp: started.Add(2 * time.Second), Type: "assistant.stream.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"source_event": "message.delta"}},
65+ {Timestamp: started.Add(3 * time.Second), Type: "result.available", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}},
66+ {Timestamp: started.Add(4 * time.Second), Type: "result.delivered", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}},
67+ })["agent-1"]
68+ if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" {
69+ t.Fatalf("primary operation = %#v, want bash tool", view.PrimaryOperation)
70+ }
71+ if view.LastObservation == nil || view.LastObservation.Type != "assistant.stream.observed" || view.LastObservation.Label() != "assistant streaming" {
72+ t.Fatalf("last observation = %#v", view.LastObservation)
73+ }
74+ if view.Result == nil || !view.Result.Available || !view.Result.Delivered || view.Result.Ref != "subagent://agent-1/result" {
75+ t.Fatalf("result = %#v", view.Result)
76+ }
77+ if got, want := view.Summary(), "tool bash open · assistant streaming"; got != want {
78+ t.Fatalf("summary = %q, want %q", got, want)
79+ }
80+}
81+
5982 func TestProjectTraceFailedAgentClosesOpenOperations(t *testing.T) {
6083 view := ProjectTrace([]TraceEvent{
6184 {Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
@@ -56,6 +56,29 @@ func TestProjectTraceClosesFailedToolByCallID(t *testing.T) {
56 }56 }
57 }57 }
58 58
59+func TestProjectTraceSelectsSpecificActivityAndProjectsResultDelivery(t *testing.T) {
60+ started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
61+ view := ProjectTrace([]TraceEvent{
62+ {Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
63+ {Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one", "name": "bash"}},
64+ {Timestamp: started.Add(2 * time.Second), Type: "assistant.stream.observed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"source_event": "message.delta"}},
65+ {Timestamp: started.Add(3 * time.Second), Type: "result.available", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}},
66+ {Timestamp: started.Add(4 * time.Second), Type: "result.delivered", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"ref": "subagent://agent-1/result"}},
67+ })["agent-1"]
68+ if view.PrimaryOperation == nil || view.PrimaryOperation.Type != "tool.started" || view.PrimaryOperation.Name != "bash" {
69+ t.Fatalf("primary operation = %#v, want bash tool", view.PrimaryOperation)
70+ }
71+ if view.LastObservation == nil || view.LastObservation.Type != "assistant.stream.observed" || view.LastObservation.Label() != "assistant streaming" {
72+ t.Fatalf("last observation = %#v", view.LastObservation)
73+ }
74+ if view.Result == nil || !view.Result.Available || !view.Result.Delivered || view.Result.Ref != "subagent://agent-1/result" {
75+ t.Fatalf("result = %#v", view.Result)
76+ }
77+ if got, want := view.Summary(), "tool bash open · assistant streaming"; got != want {
78+ t.Fatalf("summary = %q, want %q", got, want)
79+ }
80+}
81+
59 func TestProjectTraceFailedAgentClosesOpenOperations(t *testing.T) {82 func TestProjectTraceFailedAgentClosesOpenOperations(t *testing.T) {
60 view := ProjectTrace([]TraceEvent{83 view := ProjectTrace([]TraceEvent{
61 {Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},84 {Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
M packages/agent/subagents/requirement.go +8 -5
@@ -101,14 +101,14 @@ func (a *Agent) restoreRequirement(previous RequirementSnapshot) {
101101 a.lifecycleMu.Unlock()
102102 }
103103
104-func (a *Agent) markRequirementNotified() {
104+func (a *Agent) markRequirementNotified() bool {
105105 if a == nil {
106- return
106+ return false
107107 }
108108 a.lifecycleMu.Lock()
109109 if !a.requirement.Required || a.requirement.Notified {
110110 a.lifecycleMu.Unlock()
111- return
111+ return false
112112 }
113113 previous := a.requirement
114114 a.requirement.Notified = true
@@ -123,7 +123,7 @@ func (a *Agent) markRequirementNotified() {
123123 }
124124 a.lifecycleMu.Unlock()
125125 a.recordPersistenceError(err)
126- return
126+ return false
127127 }
128128 }
129129 a.lifecycleMu.Lock()
@@ -131,6 +131,7 @@ func (a *Agent) markRequirementNotified() {
131131 a.signalRequirementLocked()
132132 }
133133 a.lifecycleMu.Unlock()
134+ return true
134135 }
135136
136137 func (a *Agent) resolveRequirement(step int, result *TurnResult, errMsg string, force bool) RequirementSnapshot {
@@ -309,7 +310,9 @@ func (f *Supervisor) MarkRequirementNotified(id string) error {
309310 if a == nil {
310311 return fmt.Errorf("subagents: no such agent %q", id)
311312 }
312- a.markRequirementNotified()
313+ if a.markRequirementNotified() {
314+ a.recordTrace(TraceEvent{Type: "result.delivered", TurnID: a.CurrentTurnID(), Data: map[string]any{"ref": ResultRef(a.ID)}})
315+ }
313316 return nil
314317 }
315318
@@ -101,14 +101,14 @@ func (a *Agent) restoreRequirement(previous RequirementSnapshot) {
101 a.lifecycleMu.Unlock()101 a.lifecycleMu.Unlock()
102 }102 }
103 103
104-func (a *Agent) markRequirementNotified() {104+func (a *Agent) markRequirementNotified() bool {
105 if a == nil {105 if a == nil {
106- return106+ return false
107 }107 }
108 a.lifecycleMu.Lock()108 a.lifecycleMu.Lock()
109 if !a.requirement.Required || a.requirement.Notified {109 if !a.requirement.Required || a.requirement.Notified {
110 a.lifecycleMu.Unlock()110 a.lifecycleMu.Unlock()
111- return111+ return false
112 }112 }
113 previous := a.requirement113 previous := a.requirement
114 a.requirement.Notified = true114 a.requirement.Notified = true
@@ -123,7 +123,7 @@ func (a *Agent) markRequirementNotified() {
123 }123 }
124 a.lifecycleMu.Unlock()124 a.lifecycleMu.Unlock()
125 a.recordPersistenceError(err)125 a.recordPersistenceError(err)
126- return126+ return false
127 }127 }
128 }128 }
129 a.lifecycleMu.Lock()129 a.lifecycleMu.Lock()
@@ -131,6 +131,7 @@ func (a *Agent) markRequirementNotified() {
131 a.signalRequirementLocked()131 a.signalRequirementLocked()
132 }132 }
133 a.lifecycleMu.Unlock()133 a.lifecycleMu.Unlock()
134+ return true
134 }135 }
135 136
136 func (a *Agent) resolveRequirement(step int, result *TurnResult, errMsg string, force bool) RequirementSnapshot {137 func (a *Agent) resolveRequirement(step int, result *TurnResult, errMsg string, force bool) RequirementSnapshot {
@@ -309,7 +310,9 @@ func (f *Supervisor) MarkRequirementNotified(id string) error {
309 if a == nil {310 if a == nil {
310 return fmt.Errorf("subagents: no such agent %q", id)311 return fmt.Errorf("subagents: no such agent %q", id)
311 }312 }
312- a.markRequirementNotified()313+ if a.markRequirementNotified() {
314+ a.recordTrace(TraceEvent{Type: "result.delivered", TurnID: a.CurrentTurnID(), Data: map[string]any{"ref": ResultRef(a.ID)}})
315+ }
313 return nil316 return nil
314 }317 }
315 318
M packages/agent/subagents/requirement_test.go +25 -0
@@ -218,6 +218,31 @@ func TestRequiredOutcomePersistsAcrossReload(t *testing.T) {
218218 }
219219 }
220220
221+func TestRequirementNotificationRecordsDeliveryExactlyOnce(t *testing.T) {
222+ trace := NewMemoryTraceWriter()
223+ t.Cleanup(func() { _ = trace.Close() })
224+ a := &Agent{ID: "agent-1", trace: trace, requirement: RequirementSnapshot{Required: true, State: RequirementSatisfied, TargetTurn: 1}}
225+ s := &Supervisor{agents: map[string]*Agent{a.ID: a}}
226+ if err := s.MarkRequirementNotified(a.ID); err != nil {
227+ t.Fatal(err)
228+ }
229+ if err := s.MarkRequirementNotified(a.ID); err != nil {
230+ t.Fatal(err)
231+ }
232+ if err := trace.Flush(); err != nil {
233+ t.Fatal(err)
234+ }
235+ var delivered int
236+ for _, event := range trace.Events() {
237+ if event.Type == "result.delivered" {
238+ delivered++
239+ }
240+ }
241+ if delivered != 1 {
242+ t.Fatalf("result.delivered count = %d, want 1", delivered)
243+ }
244+}
245+
221246 func TestRequirementNotificationPersistsAndRetryClearsIt(t *testing.T) {
222247 root := t.TempDir()
223248 first := New(Config{
@@ -218,6 +218,31 @@ func TestRequiredOutcomePersistsAcrossReload(t *testing.T) {
218 }218 }
219 }219 }
220 220
221+func TestRequirementNotificationRecordsDeliveryExactlyOnce(t *testing.T) {
222+ trace := NewMemoryTraceWriter()
223+ t.Cleanup(func() { _ = trace.Close() })
224+ a := &Agent{ID: "agent-1", trace: trace, requirement: RequirementSnapshot{Required: true, State: RequirementSatisfied, TargetTurn: 1}}
225+ s := &Supervisor{agents: map[string]*Agent{a.ID: a}}
226+ if err := s.MarkRequirementNotified(a.ID); err != nil {
227+ t.Fatal(err)
228+ }
229+ if err := s.MarkRequirementNotified(a.ID); err != nil {
230+ t.Fatal(err)
231+ }
232+ if err := trace.Flush(); err != nil {
233+ t.Fatal(err)
234+ }
235+ var delivered int
236+ for _, event := range trace.Events() {
237+ if event.Type == "result.delivered" {
238+ delivered++
239+ }
240+ }
241+ if delivered != 1 {
242+ t.Fatalf("result.delivered count = %d, want 1", delivered)
243+ }
244+}
245+
221 func TestRequirementNotificationPersistsAndRetryClearsIt(t *testing.T) {246 func TestRequirementNotificationPersistsAndRetryClearsIt(t *testing.T) {
222 root := t.TempDir()247 root := t.TempDir()
223 first := New(Config{248 first := New(Config{
M packages/agent/subagents/result.go +1 -0
@@ -581,6 +581,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
581581 a.lifecycleMu.Lock()
582582 a.resultRef = ResultRef(a.ID)
583583 a.lifecycleMu.Unlock()
584+ a.recordTrace(TraceEvent{Type: "result.available", TurnID: result.TurnID, Data: map[string]any{"ref": ResultRef(a.ID)}})
584585 a.resolveRequirement(0, result, "", true)
585586 }
586587
@@ -581,6 +581,7 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
581 a.lifecycleMu.Lock()581 a.lifecycleMu.Lock()
582 a.resultRef = ResultRef(a.ID)582 a.resultRef = ResultRef(a.ID)
583 a.lifecycleMu.Unlock()583 a.lifecycleMu.Unlock()
584+ a.recordTrace(TraceEvent{Type: "result.available", TurnID: result.TurnID, Data: map[string]any{"ref": ResultRef(a.ID)}})
584 a.resolveRequirement(0, result, "", true)585 a.resolveRequirement(0, result, "", true)
585 }586 }
586 587
M packages/agent/subagents/result_test.go +18 -0
@@ -182,6 +182,24 @@ func TestWriteTurnResultCleansTemporaryFileAfterRenameFailure(t *testing.T) {
182182 }
183183 }
184184
185+func TestEnsureResultRecordsAvailabilityAfterDurableWrite(t *testing.T) {
186+ state := t.TempDir()
187+ trace := NewMemoryTraceWriter()
188+ t.Cleanup(func() { _ = trace.Close() })
189+ agent := &Agent{ID: "agent-1", stateDir: state, trace: trace, maxOutputBytes: 1024, maxOutputLines: 10}
190+ (&Supervisor{}).ensureResult(agent, StatusDone, nil)
191+ if err := trace.Flush(); err != nil {
192+ t.Fatal(err)
193+ }
194+ view := ProjectTrace(trace.Events())[agent.ID]
195+ if view.Result == nil || !view.Result.Available || view.Result.Ref != ResultRef(agent.ID) {
196+ t.Fatalf("result trace = %#v", view.Result)
197+ }
198+ if _, err := os.Stat(resultPath(state)); err != nil {
199+ t.Fatalf("durable result missing: %v", err)
200+ }
201+}
202+
185203 func TestCaptureWorkspaceFailureIsDurableAndPrivate(t *testing.T) {
186204 makeAgent := func(stateDir string, capture func() (WorkspaceCapture, error)) *Agent {
187205 return &Agent{ID: "agent-1", stateDir: stateDir, workspaceCapture: capture}
@@ -182,6 +182,24 @@ func TestWriteTurnResultCleansTemporaryFileAfterRenameFailure(t *testing.T) {
182 }182 }
183 }183 }
184 184
185+func TestEnsureResultRecordsAvailabilityAfterDurableWrite(t *testing.T) {
186+ state := t.TempDir()
187+ trace := NewMemoryTraceWriter()
188+ t.Cleanup(func() { _ = trace.Close() })
189+ agent := &Agent{ID: "agent-1", stateDir: state, trace: trace, maxOutputBytes: 1024, maxOutputLines: 10}
190+ (&Supervisor{}).ensureResult(agent, StatusDone, nil)
191+ if err := trace.Flush(); err != nil {
192+ t.Fatal(err)
193+ }
194+ view := ProjectTrace(trace.Events())[agent.ID]
195+ if view.Result == nil || !view.Result.Available || view.Result.Ref != ResultRef(agent.ID) {
196+ t.Fatalf("result trace = %#v", view.Result)
197+ }
198+ if _, err := os.Stat(resultPath(state)); err != nil {
199+ t.Fatalf("durable result missing: %v", err)
200+ }
201+}
202+
185 func TestCaptureWorkspaceFailureIsDurableAndPrivate(t *testing.T) {203 func TestCaptureWorkspaceFailureIsDurableAndPrivate(t *testing.T) {
186 makeAgent := func(stateDir string, capture func() (WorkspaceCapture, error)) *Agent {204 makeAgent := func(stateDir string, capture func() (WorkspaceCapture, error)) *Agent {
187 return &Agent{ID: "agent-1", stateDir: stateDir, workspaceCapture: capture}205 return &Agent{ID: "agent-1", stateDir: stateDir, workspaceCapture: capture}
M packages/agent/subagents/runner.go +4 -2
@@ -904,7 +904,7 @@ func recordWorkerTrace(agent *Agent, ev Event) {
904904 return
905905 }
906906 typeName := strings.ReplaceAll(ev.Type, "_", ".")
907- traceType := "worker.event"
907+ traceType := "worker.protocol.observed"
908908 switch ev.Type {
909909 case "turn_start", EventTurnStarted:
910910 traceType = "turn.started"
@@ -916,12 +916,14 @@ func recordWorkerTrace(agent *Agent, ev Event) {
916916 traceType = "tool.started"
917917 case "tool_result", EventToolFinished:
918918 traceType = "tool.finished"
919+ case EventMessageDelta:
920+ traceType = "assistant.stream.observed"
919921 case "agent_ready", EventAgentReady:
920922 traceType = "agent.ready"
921923 case "agent_stopped":
922924 traceType = "agent.stopped"
923925 }
924- data := map[string]any{"worker_event": typeName}
926+ data := map[string]any{"source_event": typeName}
925927 if name, ok := ev.Data["name"].(string); ok && name != "" {
926928 data["name"] = name
927929 }
@@ -904,7 +904,7 @@ func recordWorkerTrace(agent *Agent, ev Event) {
904 return904 return
905 }905 }
906 typeName := strings.ReplaceAll(ev.Type, "_", ".")906 typeName := strings.ReplaceAll(ev.Type, "_", ".")
907- traceType := "worker.event"907+ traceType := "worker.protocol.observed"
908 switch ev.Type {908 switch ev.Type {
909 case "turn_start", EventTurnStarted:909 case "turn_start", EventTurnStarted:
910 traceType = "turn.started"910 traceType = "turn.started"
@@ -916,12 +916,14 @@ func recordWorkerTrace(agent *Agent, ev Event) {
916 traceType = "tool.started"916 traceType = "tool.started"
917 case "tool_result", EventToolFinished:917 case "tool_result", EventToolFinished:
918 traceType = "tool.finished"918 traceType = "tool.finished"
919+ case EventMessageDelta:
920+ traceType = "assistant.stream.observed"
919 case "agent_ready", EventAgentReady:921 case "agent_ready", EventAgentReady:
920 traceType = "agent.ready"922 traceType = "agent.ready"
921 case "agent_stopped":923 case "agent_stopped":
922 traceType = "agent.stopped"924 traceType = "agent.stopped"
923 }925 }
924- data := map[string]any{"worker_event": typeName}926+ data := map[string]any{"source_event": typeName}
925 if name, ok := ev.Data["name"].(string); ok && name != "" {927 if name, ok := ev.Data["name"].(string); ok && name != "" {
926 data["name"] = name928 data["name"] = name
927 }929 }
M packages/agent/subagents/runner_test.go +30 -0
@@ -801,6 +801,36 @@ func TestEventCounterRejectsNegativeFractionalAndOverflowValues(t *testing.T) {
801801 }
802802 }
803803
804+func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) {
805+ trace := NewMemoryTraceWriter()
806+ t.Cleanup(func() { _ = trace.Close() })
807+ agent := &Agent{ID: "agent-1", trace: trace}
808+ recordWorkerTrace(agent, Event{Type: EventMessageDelta, TurnID: "turn-1", Data: map[string]any{"delta": "sensitive answer"}})
809+ recordWorkerTrace(agent, Event{Type: "future_event", TurnID: "turn-1", Data: map[string]any{"secret": "hidden"}})
810+ if err := trace.Flush(); err != nil {
811+ t.Fatal(err)
812+ }
813+ events := trace.Events()
814+ if len(events) != 2 {
815+ t.Fatalf("events = %#v", events)
816+ }
817+ if got, want := events[0].Type, "assistant.stream.observed"; got != want {
818+ t.Fatalf("type = %q, want %q", got, want)
819+ }
820+ if _, found := events[0].Data["delta"]; found {
821+ t.Fatalf("stream payload leaked: %#v", events[0].Data)
822+ }
823+ if got, want := events[1].Type, "worker.protocol.observed"; got != want {
824+ t.Fatalf("type = %q, want %q", got, want)
825+ }
826+ if got, _ := events[1].Data["source_event"].(string); got != "future.event" {
827+ t.Fatalf("source_event = %q", got)
828+ }
829+ if _, found := events[1].Data["secret"]; found {
830+ t.Fatalf("worker payload leaked: %#v", events[1].Data)
831+ }
832+}
833+
804834 func TestApplyEventToSinkProjectsToolActivityIntoTranscript(t *testing.T) {
805835 events := []Event{
806836 {Type: EventToolStarted, Data: map[string]any{"name": "bash"}},
@@ -801,6 +801,36 @@ func TestEventCounterRejectsNegativeFractionalAndOverflowValues(t *testing.T) {
801 }801 }
802 }802 }
803 803
804+func TestRecordWorkerTraceNormalizesSafeLiveObservations(t *testing.T) {
805+ trace := NewMemoryTraceWriter()
806+ t.Cleanup(func() { _ = trace.Close() })
807+ agent := &Agent{ID: "agent-1", trace: trace}
808+ recordWorkerTrace(agent, Event{Type: EventMessageDelta, TurnID: "turn-1", Data: map[string]any{"delta": "sensitive answer"}})
809+ recordWorkerTrace(agent, Event{Type: "future_event", TurnID: "turn-1", Data: map[string]any{"secret": "hidden"}})
810+ if err := trace.Flush(); err != nil {
811+ t.Fatal(err)
812+ }
813+ events := trace.Events()
814+ if len(events) != 2 {
815+ t.Fatalf("events = %#v", events)
816+ }
817+ if got, want := events[0].Type, "assistant.stream.observed"; got != want {
818+ t.Fatalf("type = %q, want %q", got, want)
819+ }
820+ if _, found := events[0].Data["delta"]; found {
821+ t.Fatalf("stream payload leaked: %#v", events[0].Data)
822+ }
823+ if got, want := events[1].Type, "worker.protocol.observed"; got != want {
824+ t.Fatalf("type = %q, want %q", got, want)
825+ }
826+ if got, _ := events[1].Data["source_event"].(string); got != "future.event" {
827+ t.Fatalf("source_event = %q", got)
828+ }
829+ if _, found := events[1].Data["secret"]; found {
830+ t.Fatalf("worker payload leaked: %#v", events[1].Data)
831+ }
832+}
833+
804 func TestApplyEventToSinkProjectsToolActivityIntoTranscript(t *testing.T) {834 func TestApplyEventToSinkProjectsToolActivityIntoTranscript(t *testing.T) {
805 events := []Event{835 events := []Event{
806 {Type: EventToolStarted, Data: map[string]any{"name": "bash"}},836 {Type: EventToolStarted, Data: map[string]any{"name": "bash"}},
M packages/agent/tools/subagent_status.go +36 -16
@@ -34,16 +34,17 @@ type subagentStatusResponse struct {
3434 }
3535
3636 type subagentStatusEntry struct {
37- ID string `json:"agent_id"`
38- Operation *statusOperation `json:"operation,omitempty"`
39- Terminal string `json:"terminal,omitempty"`
40- LastEvent *statusLastEvent `json:"last_event,omitempty"`
41- StartedAt time.Time `json:"started_at"`
42- LifetimeTurns int `json:"lifetime_turns"`
43- CurrentRunTurns int `json:"current_run_turns"`
44- TaskSummary string `json:"task_summary,omitempty"`
45- Requirement *statusRequirement `json:"requirement,omitempty"`
46- Result *subagentStatusResult `json:"result,omitempty"`
37+ ID string `json:"agent_id"`
38+ PrimaryOperation *statusOperation `json:"primary_operation,omitempty"`
39+ Terminal string `json:"terminal,omitempty"`
40+ LastObservation *statusLastEvent `json:"last_observation,omitempty"`
41+ LastEvent *statusLastEvent `json:"last_event,omitempty"`
42+ StartedAt time.Time `json:"started_at"`
43+ LifetimeTurns int `json:"lifetime_turns"`
44+ CurrentRunTurns int `json:"current_run_turns"`
45+ TaskSummary string `json:"task_summary,omitempty"`
46+ Requirement *statusRequirement `json:"requirement,omitempty"`
47+ Result *subagentStatusResult `json:"result,omitempty"`
4748 }
4849
4950 type statusOperation struct {
@@ -64,9 +65,11 @@ type statusRequirement struct {
6465 }
6566
6667 type subagentStatusResult struct {
67- State string `json:"state"`
68- Available bool `json:"available"`
69- Ref string `json:"ref,omitempty"`
68+ State string `json:"state"`
69+ Available bool `json:"available"`
70+ Ref string `json:"ref,omitempty"`
71+ Delivered bool `json:"delivered,omitempty"`
72+ DeliveryFailed bool `json:"delivery_failed,omitempty"`
7073 }
7174
7275 const subagentStatusSchema = `{
@@ -157,9 +160,15 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent
157160 CurrentRunTurns: snapshot.CurrentRunTurns,
158161 TaskSummary: summarizeSubagentTask(snapshot.Task),
159162 }
160- if len(view.OpenOperations) != 0 {
161- operation := view.OpenOperations[0]
162- entry.Operation = &statusOperation{Type: operation.Type, StartedAt: operation.StartedAt}
163+ primary := view.PrimaryOperation
164+ if primary == nil && len(view.OpenOperations) != 0 {
165+ primary = &view.OpenOperations[0]
166+ }
167+ if primary != nil {
168+ entry.PrimaryOperation = &statusOperation{Type: primary.Type, StartedAt: primary.StartedAt}
169+ }
170+ if view.LastObservation != nil {
171+ entry.LastObservation = &statusLastEvent{Type: view.LastObservation.Type, At: view.LastObservation.At}
163172 }
164173 if view.LastEvent.Type != "" {
165174 entry.LastEvent = &statusLastEvent{Type: view.LastEvent.Type, At: view.LastEvent.Timestamp}
@@ -179,6 +188,17 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent
179188 Ref: snapshot.ResultRef,
180189 }
181190 }
191+ if view.Result != nil {
192+ if entry.Result == nil {
193+ entry.Result = &subagentStatusResult{}
194+ }
195+ entry.Result.Available = view.Result.Available
196+ if view.Result.Ref != "" {
197+ entry.Result.Ref = view.Result.Ref
198+ }
199+ entry.Result.Delivered = view.Result.Delivered
200+ entry.Result.DeliveryFailed = view.Result.Failed
201+ }
182202 return entry
183203 }
184204
@@ -34,16 +34,17 @@ type subagentStatusResponse struct {
34 }34 }
35 35
36 type subagentStatusEntry struct {36 type subagentStatusEntry struct {
37- ID string `json:"agent_id"`37+ ID string `json:"agent_id"`
38- Operation *statusOperation `json:"operation,omitempty"`38+ PrimaryOperation *statusOperation `json:"primary_operation,omitempty"`
39- Terminal string `json:"terminal,omitempty"`39+ Terminal string `json:"terminal,omitempty"`
40- LastEvent *statusLastEvent `json:"last_event,omitempty"`40+ LastObservation *statusLastEvent `json:"last_observation,omitempty"`
41- StartedAt time.Time `json:"started_at"`41+ LastEvent *statusLastEvent `json:"last_event,omitempty"`
42- LifetimeTurns int `json:"lifetime_turns"`42+ StartedAt time.Time `json:"started_at"`
43- CurrentRunTurns int `json:"current_run_turns"`43+ LifetimeTurns int `json:"lifetime_turns"`
44- TaskSummary string `json:"task_summary,omitempty"`44+ CurrentRunTurns int `json:"current_run_turns"`
45- Requirement *statusRequirement `json:"requirement,omitempty"`45+ TaskSummary string `json:"task_summary,omitempty"`
46- Result *subagentStatusResult `json:"result,omitempty"`46+ Requirement *statusRequirement `json:"requirement,omitempty"`
47+ Result *subagentStatusResult `json:"result,omitempty"`
47 }48 }
48 49
49 type statusOperation struct {50 type statusOperation struct {
@@ -64,9 +65,11 @@ type statusRequirement struct {
64 }65 }
65 66
66 type subagentStatusResult struct {67 type subagentStatusResult struct {
67- State string `json:"state"`68+ State string `json:"state"`
68- Available bool `json:"available"`69+ Available bool `json:"available"`
69- Ref string `json:"ref,omitempty"`70+ Ref string `json:"ref,omitempty"`
71+ Delivered bool `json:"delivered,omitempty"`
72+ DeliveryFailed bool `json:"delivery_failed,omitempty"`
70 }73 }
71 74
72 const subagentStatusSchema = `{75 const subagentStatusSchema = `{
@@ -157,9 +160,15 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent
157 CurrentRunTurns: snapshot.CurrentRunTurns,160 CurrentRunTurns: snapshot.CurrentRunTurns,
158 TaskSummary: summarizeSubagentTask(snapshot.Task),161 TaskSummary: summarizeSubagentTask(snapshot.Task),
159 }162 }
160- if len(view.OpenOperations) != 0 {163+ primary := view.PrimaryOperation
161- operation := view.OpenOperations[0]164+ if primary == nil && len(view.OpenOperations) != 0 {
162- entry.Operation = &statusOperation{Type: operation.Type, StartedAt: operation.StartedAt}165+ primary = &view.OpenOperations[0]
166+ }
167+ if primary != nil {
168+ entry.PrimaryOperation = &statusOperation{Type: primary.Type, StartedAt: primary.StartedAt}
169+ }
170+ if view.LastObservation != nil {
171+ entry.LastObservation = &statusLastEvent{Type: view.LastObservation.Type, At: view.LastObservation.At}
163 }172 }
164 if view.LastEvent.Type != "" {173 if view.LastEvent.Type != "" {
165 entry.LastEvent = &statusLastEvent{Type: view.LastEvent.Type, At: view.LastEvent.Timestamp}174 entry.LastEvent = &statusLastEvent{Type: view.LastEvent.Type, At: view.LastEvent.Timestamp}
@@ -179,6 +188,17 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent
179 Ref: snapshot.ResultRef,188 Ref: snapshot.ResultRef,
180 }189 }
181 }190 }
191+ if view.Result != nil {
192+ if entry.Result == nil {
193+ entry.Result = &subagentStatusResult{}
194+ }
195+ entry.Result.Available = view.Result.Available
196+ if view.Result.Ref != "" {
197+ entry.Result.Ref = view.Result.Ref
198+ }
199+ entry.Result.Delivered = view.Result.Delivered
200+ entry.Result.DeliveryFailed = view.Result.Failed
201+ }
182 return entry202 return entry
183 }203 }
184 204
M packages/agent/tools/subagent_status_test.go +21 -0
@@ -61,6 +61,27 @@ func TestSubagentStatusIncludesTurnCounters(t *testing.T) {
6161 }
6262 }
6363
64+func TestPublicSubagentStatusUsesOnlyPrimaryOperation(t *testing.T) {
65+ at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
66+ entry := publicSubagentStatus(subagents.AgentSnapshot{ID: "agent-1"}, subagents.AgentTraceView{
67+ OpenOperations: []subagents.Operation{{Type: "turn.started", StartedAt: at}, {Type: "tool.started", StartedAt: at.Add(time.Second)}},
68+ })
69+ if entry.PrimaryOperation == nil || entry.PrimaryOperation.Type != "turn.started" {
70+ t.Fatalf("primary operation = %#v", entry.PrimaryOperation)
71+ }
72+ encoded, err := json.Marshal(entry)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ var fields map[string]any
77+ if err := json.Unmarshal(encoded, &fields); err != nil {
78+ t.Fatal(err)
79+ }
80+ if _, found := fields["operation"]; found {
81+ t.Fatalf("obsolete operation field in %#v", fields)
82+ }
83+}
84+
6485 func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) {
6586 var schema struct {
6687 Type string `json:"type"`
@@ -61,6 +61,27 @@ func TestSubagentStatusIncludesTurnCounters(t *testing.T) {
61 }61 }
62 }62 }
63 63
64+func TestPublicSubagentStatusUsesOnlyPrimaryOperation(t *testing.T) {
65+ at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
66+ entry := publicSubagentStatus(subagents.AgentSnapshot{ID: "agent-1"}, subagents.AgentTraceView{
67+ OpenOperations: []subagents.Operation{{Type: "turn.started", StartedAt: at}, {Type: "tool.started", StartedAt: at.Add(time.Second)}},
68+ })
69+ if entry.PrimaryOperation == nil || entry.PrimaryOperation.Type != "turn.started" {
70+ t.Fatalf("primary operation = %#v", entry.PrimaryOperation)
71+ }
72+ encoded, err := json.Marshal(entry)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ var fields map[string]any
77+ if err := json.Unmarshal(encoded, &fields); err != nil {
78+ t.Fatal(err)
79+ }
80+ if _, found := fields["operation"]; found {
81+ t.Fatalf("obsolete operation field in %#v", fields)
82+ }
83+}
84+
64 func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) {85 func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) {
65 var schema struct {86 var schema struct {
66 Type string `json:"type"`87 Type string `json:"type"`

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close