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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 137 | 137 | ||
| 138 | 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 | 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 | 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 | 165 | ## Local execution trace | |
| 166 | 166 | ||
| @@ -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 trace | 165 | ## 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 [] | |||
| 17 | 17 | lines := make([]string, 0, len(snapshots)) | |
| 18 | 18 | for _, snapshot := range snapshots { | |
| 19 | 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 | 25 | 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 | 28 | if line != "" { | |
| 25 | 29 | lines = append(lines, line) | |
| 26 | 30 | } | |
| @@ -28,7 +32,7 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots [] | |||
| 28 | 32 | 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 | 36 | name := sanitizeSubagentIndicatorText(snapshot.Subagent) | |
| 33 | 37 | if name == "" { | |
| 34 | 38 | name = sanitizeSubagentIndicatorText(snapshot.ID) | |
| @@ -41,6 +45,9 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba | |||
| 41 | 45 | spinnerGlyph = "." | |
| 42 | 46 | } | |
| 43 | 47 | activity := operation.Label() | |
| 48 | + | if observation != nil { | |
| 49 | + | activity = observation.Label() + " · " + formatSubagentActivityAge(observation.At, now) + " ago" | |
| 50 | + | } | |
| 44 | 51 | age := formatSubagentActivityAge(operation.StartedAt, now) | |
| 45 | 52 | plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2) | |
| 46 | 53 | if plain == "" { | |
| @@ -167,7 +174,8 @@ func (i *Interactive) activeSubagentActivitySnapshots() ([]subagents.AgentSnapsh | |||
| 167 | 174 | if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession { | |
| 168 | 175 | 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 | 179 | continue | |
| 172 | 180 | } | |
| 173 | 181 | 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 | continue | 25 | 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 lines | 32 | 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 | continue | 175 | 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 | continue | 179 | 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) { | |||
| 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 | 40 | func TestRenderSubagentActivityLinesKeepsNameAndAgeOnNarrowTerminals(t *testing.T) { | |
| 31 | 41 | now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) | |
| 32 | 42 | 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 { | |||
| 1457 | 1457 | } | |
| 1458 | 1458 | ||
| 1459 | 1459 | 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() | |
| 1471 | 1461 | } | |
| 1472 | 1462 | ||
| 1473 | 1463 | // renderSupervisorTranscriptBlocks converts the agent's flat transcript | |
| @@ -1634,14 +1624,17 @@ func (d *subagentsDialog) traceView(id string) subagents.AgentTraceView { | |||
| 1634 | 1624 | // tool open 3m fix-login-12345 3m inspect main.go | |
| 1635 | 1625 | // completed write-tests-67890 1h add coverage | |
| 1636 | 1626 | 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) | |
| 1643 | 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 | 1639 | age := formatAge(r.Started) | |
| 1647 | 1640 | 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 transcript | 1463 | // 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.go | 1624 | // tool open 3m fix-login-12345 3m inspect main.go | ||
| 1635 | // completed write-tests-67890 1h add coverage | 1625 | // 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.Terminal | 1629 | + | 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 | |||
| 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 | 38 | func TestSubagentsDialogEmptyState(t *testing.T) { | |
| 28 | 39 | d := newSubagentsDialog() | |
| 29 | 40 | 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 { | |||
| 14 | 14 | AgentID string | |
| 15 | 15 | TurnID string | |
| 16 | 16 | CallID string | |
| 17 | + | Name string | |
| 17 | 18 | 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 | 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 | 85 | // AgentTraceView is the only user-facing execution state. It intentionally | |
| 36 | 86 | // contains operations and terminal facts rather than lifecycle guesses such as | |
| 37 | 87 | // working, idle, or alive. | |
| 38 | 88 | 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" | |
| 43 | 129 | } | |
| 44 | 130 | ||
| 45 | 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 | 134 | func ProjectTrace(events []TraceEvent) map[string]AgentTraceView { | |
| 48 | 135 | views := make(map[string]AgentTraceView) | |
| 49 | 136 | open := make(map[string]map[string]Operation) | |
| @@ -60,11 +147,18 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView { | |||
| 60 | 147 | open[event.AgentID] = make(map[string]Operation) | |
| 61 | 148 | } | |
| 62 | 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 | 153 | if ends { | |
| 66 | 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 | 162 | if terminal != "" { | |
| 69 | 163 | view.Terminal = terminal | |
| 70 | 164 | delete(open, event.AgentID) | |
| @@ -78,6 +172,9 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView { | |||
| 78 | 172 | } | |
| 79 | 173 | sort.Slice(view.OpenOperations, func(i, j int) bool { | |
| 80 | 174 | left, right := view.OpenOperations[i], view.OpenOperations[j] | |
| 175 | + | if operationPriority(left) != operationPriority(right) { | |
| 176 | + | return operationPriority(left) < operationPriority(right) | |
| 177 | + | } | |
| 81 | 178 | if !left.StartedAt.Equal(right.StartedAt) { | |
| 82 | 179 | return left.StartedAt.Before(right.StartedAt) | |
| 83 | 180 | } | |
| @@ -89,11 +186,65 @@ func ProjectTrace(events []TraceEvent) map[string]AgentTraceView { | |||
| 89 | 186 | } | |
| 90 | 187 | return left.CallID < right.CallID | |
| 91 | 188 | }) | |
| 189 | + | if len(view.OpenOperations) != 0 { | |
| 190 | + | primary := view.OpenOperations[0] | |
| 191 | + | view.PrimaryOperation = &primary | |
| 192 | + | } | |
| 92 | 193 | views[agentID] = view | |
| 93 | 194 | } | |
| 94 | 195 | 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 | 248 | func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) { | |
| 98 | 249 | callID, _ := event.Data["call_id"].(string) | |
| 99 | 250 | key = event.Type + ":" + event.TurnID + ":" + callID | |
| @@ -106,6 +257,14 @@ func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal st | |||
| 106 | 257 | return "tool:" + event.TurnID + ":" + callID, true, false, "" | |
| 107 | 258 | case "tool.finished", "tool.failed", "tool.cancelled": | |
| 108 | 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 | 268 | case "agent.finished": | |
| 110 | 269 | return key, false, false, "completed" | |
| 111 | 270 | case "agent.failed": | |
| @@ -14,6 +14,7 @@ type Operation struct { | |||||
| 14 | AgentID string | 14 | AgentID string | ||
| 15 | TurnID string | 15 | TurnID string | ||
| 16 | CallID string | 16 | CallID string | ||
| 17 | + | Name string | |||
| 17 | StartedAt time.Time | 18 | 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 intentionally | 85 | // AgentTraceView is the only user-facing execution state. It intentionally | ||
| 36 | // contains operations and terminal facts rather than lifecycle guesses such as | 86 | // 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 string | 89 | + | AgentID string |
| 40 | - | LastEvent TraceEvent | 90 | + | LastEvent TraceEvent |
| 41 | - | OpenOperations []Operation | 91 | + | LastObservation *LiveObservation |
| 42 | - | Terminal string | 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" | |||
| 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 = terminal | 163 | 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.CallID | 187 | 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] = view | 193 | views[agentID] = view | ||
| 93 | } | 194 | } | ||
| 94 | return views | 195 | 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 + ":" + callID | 250 | 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) { | |||
| 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 | 82 | func TestProjectTraceFailedAgentClosesOpenOperations(t *testing.T) { | |
| 60 | 83 | view := ProjectTrace([]TraceEvent{ | |
| 61 | 84 | {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) { | |||
| 101 | 101 | a.lifecycleMu.Unlock() | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | - | func (a *Agent) markRequirementNotified() { | |
| 104 | + | func (a *Agent) markRequirementNotified() bool { | |
| 105 | 105 | if a == nil { | |
| 106 | - | return | |
| 106 | + | return false | |
| 107 | 107 | } | |
| 108 | 108 | a.lifecycleMu.Lock() | |
| 109 | 109 | if !a.requirement.Required || a.requirement.Notified { | |
| 110 | 110 | a.lifecycleMu.Unlock() | |
| 111 | - | return | |
| 111 | + | return false | |
| 112 | 112 | } | |
| 113 | 113 | previous := a.requirement | |
| 114 | 114 | a.requirement.Notified = true | |
| @@ -123,7 +123,7 @@ func (a *Agent) markRequirementNotified() { | |||
| 123 | 123 | } | |
| 124 | 124 | a.lifecycleMu.Unlock() | |
| 125 | 125 | a.recordPersistenceError(err) | |
| 126 | - | return | |
| 126 | + | return false | |
| 127 | 127 | } | |
| 128 | 128 | } | |
| 129 | 129 | a.lifecycleMu.Lock() | |
| @@ -131,6 +131,7 @@ func (a *Agent) markRequirementNotified() { | |||
| 131 | 131 | a.signalRequirementLocked() | |
| 132 | 132 | } | |
| 133 | 133 | a.lifecycleMu.Unlock() | |
| 134 | + | return true | |
| 134 | 135 | } | |
| 135 | 136 | ||
| 136 | 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 | 310 | if a == nil { | |
| 310 | 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 | 316 | return nil | |
| 314 | 317 | } | |
| 315 | 318 | ||
| @@ -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 | - | return | 106 | + | 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 | - | return | 111 | + | return false |
| 112 | } | 112 | } | ||
| 113 | previous := a.requirement | 113 | previous := a.requirement | ||
| 114 | a.requirement.Notified = true | 114 | 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 | - | return | 126 | + | 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 nil | 316 | return nil | ||
| 314 | } | 317 | } | ||
| 315 | 318 | ||||
M packages/agent/subagents/requirement_test.go +25 -0
| @@ -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 | 246 | func TestRequirementNotificationPersistsAndRetryClearsIt(t *testing.T) { | |
| 222 | 247 | root := t.TempDir() | |
| 223 | 248 | 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) { | |||
| 581 | 581 | a.lifecycleMu.Lock() | |
| 582 | 582 | a.resultRef = ResultRef(a.ID) | |
| 583 | 583 | a.lifecycleMu.Unlock() | |
| 584 | + | a.recordTrace(TraceEvent{Type: "result.available", TurnID: result.TurnID, Data: map[string]any{"ref": ResultRef(a.ID)}}) | |
| 584 | 585 | a.resolveRequirement(0, result, "", true) | |
| 585 | 586 | } | |
| 586 | 587 | ||
| @@ -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) { | |||
| 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 | 203 | func TestCaptureWorkspaceFailureIsDurableAndPrivate(t *testing.T) { | |
| 186 | 204 | makeAgent := func(stateDir string, capture func() (WorkspaceCapture, error)) *Agent { | |
| 187 | 205 | 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) { | |||
| 904 | 904 | return | |
| 905 | 905 | } | |
| 906 | 906 | typeName := strings.ReplaceAll(ev.Type, "_", ".") | |
| 907 | - | traceType := "worker.event" | |
| 907 | + | traceType := "worker.protocol.observed" | |
| 908 | 908 | switch ev.Type { | |
| 909 | 909 | case "turn_start", EventTurnStarted: | |
| 910 | 910 | traceType = "turn.started" | |
| @@ -916,12 +916,14 @@ func recordWorkerTrace(agent *Agent, ev Event) { | |||
| 916 | 916 | traceType = "tool.started" | |
| 917 | 917 | case "tool_result", EventToolFinished: | |
| 918 | 918 | traceType = "tool.finished" | |
| 919 | + | case EventMessageDelta: | |
| 920 | + | traceType = "assistant.stream.observed" | |
| 919 | 921 | case "agent_ready", EventAgentReady: | |
| 920 | 922 | traceType = "agent.ready" | |
| 921 | 923 | case "agent_stopped": | |
| 922 | 924 | traceType = "agent.stopped" | |
| 923 | 925 | } | |
| 924 | - | data := map[string]any{"worker_event": typeName} | |
| 926 | + | data := map[string]any{"source_event": typeName} | |
| 925 | 927 | if name, ok := ev.Data["name"].(string); ok && name != "" { | |
| 926 | 928 | data["name"] = name | |
| 927 | 929 | } | |
| @@ -904,7 +904,7 @@ func recordWorkerTrace(agent *Agent, ev Event) { | |||||
| 904 | return | 904 | 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"] = name | 928 | data["name"] = name | ||
| 927 | } | 929 | } | ||
M packages/agent/subagents/runner_test.go +30 -0
| @@ -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 | 834 | func TestApplyEventToSinkProjectsToolActivityIntoTranscript(t *testing.T) { | |
| 805 | 835 | events := []Event{ | |
| 806 | 836 | {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 { | |||
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | 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"` | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | type statusOperation struct { | |
| @@ -64,9 +65,11 @@ type statusRequirement struct { | |||
| 64 | 65 | } | |
| 65 | 66 | ||
| 66 | 67 | 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"` | |
| 70 | 73 | } | |
| 71 | 74 | ||
| 72 | 75 | const subagentStatusSchema = `{ | |
| @@ -157,9 +160,15 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent | |||
| 157 | 160 | CurrentRunTurns: snapshot.CurrentRunTurns, | |
| 158 | 161 | TaskSummary: summarizeSubagentTask(snapshot.Task), | |
| 159 | 162 | } | |
| 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} | |
| 163 | 172 | } | |
| 164 | 173 | if view.LastEvent.Type != "" { | |
| 165 | 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 | 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 | 202 | return entry | |
| 183 | 203 | } | |
| 184 | 204 | ||
| @@ -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 entry | 202 | 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) { | |||
| 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 | 85 | func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) { | |
| 65 | 86 | var schema struct { | |
| 66 | 87 | 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"` | ||