feat(subagents): add session-scoped dashboard
- commit
- 997f27c3519c53f62a2bd92bd52a7fa83bfbe377
- author
- brice · · 2026-08-11 03:28 UTC
- parent
- 94b5351
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +414 -71
M docs/subagents.md +2 -0
| @@ -139,6 +139,8 @@ Every child has independent process and turn state. A process may be `alive` whi | |||
| 139 | 139 | ||
| 140 | 140 | During an active delegated turn, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), current activity, and elapsed time since the latest activity or heartbeat. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted workers collapse into one count summary so the editor remains visible. Rows disappear when the turn becomes idle, the process detaches, or the turn reaches a terminal state; use `/subagents` for durable history and results. | |
| 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. | |
| 143 | + | ||
| 142 | 144 | The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol. | |
| 143 | 145 | ||
| 144 | 146 | A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Every manager call returns immediately, and every worker outcome is delivered asynchronously through `[auto-subagents update]`; required outcome state is also injected as high-priority `[required-subagents update]` context. The primary remains available for coordination and independent work while children run. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references: | |
| @@ -139,6 +139,8 @@ Every child has independent process and turn state. A process may be `alive` whi | |||||
| 139 | 139 | ||||
| 140 | During an active delegated turn, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), current activity, and elapsed time since the latest activity or heartbeat. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted workers collapse into one count summary so the editor remains visible. Rows disappear when the turn becomes idle, the process detaches, or the turn reaches a terminal state; use `/subagents` for durable history and results. | 140 | During an active delegated turn, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), current activity, and elapsed time since the latest activity or heartbeat. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted workers collapse into one count summary so the editor remains visible. Rows disappear when the turn becomes idle, the process detaches, or the turn reaches a terminal state; use `/subagents` for durable history and results. | ||
| 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. | |||
| 143 | + | ||||
| 142 | The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol. | 144 | The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol. | ||
| 143 | 145 | ||||
| 144 | A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Every manager call returns immediately, and every worker outcome is delivered asynchronously through `[auto-subagents update]`; required outcome state is also injected as high-priority `[required-subagents update]` context. The primary remains available for coordination and independent work while children run. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references: | 146 | A completed worker emits a `turn.result` event and writes `result.json`. Intentional cancellation records `shutdown_origin` as `targeted`, `session`, `deadline`, or `process`; manager updates describe that cancellation instead of reporting the worker as generically failed with `context canceled`. Every manager call returns immediately, and every worker outcome is delivered asynchronously through `[auto-subagents update]`; required outcome state is also injected as high-priority `[required-subagents update]` context. The primary remains available for coordination and independent work while children run. These host-delivered outcomes, rather than polling or inspecting state, are the signal for the primary to continue. Inline output is bounded; after delivery, inspect the full session through the stable references: | ||
M packages/agent/modes/interactive.go +9 -0
| @@ -1937,6 +1937,15 @@ func (i *Interactive) redraw() { | |||
| 1937 | 1937 | i.sessionDialog.MaxRows = avail | |
| 1938 | 1938 | dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols) | |
| 1939 | 1939 | case i.subagentsDialog.Active(): | |
| 1940 | + | // The dashboard is composed into the bottom-sticky dialog band. | |
| 1941 | + | // Reserve its leading gap, frame padding, one chat row, and the | |
| 1942 | + | // renderer margin so a long list or expanded snapshot never pushes | |
| 1943 | + | // its top rows out of the terminal viewport. | |
| 1944 | + | avail := rows - 4 | |
| 1945 | + | if avail < 4 { | |
| 1946 | + | avail = 4 | |
| 1947 | + | } | |
| 1948 | + | i.subagentsDialog.SetMaxRows(avail) | |
| 1940 | 1949 | dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols) | |
| 1941 | 1950 | case i.jumpDialog.Active(): | |
| 1942 | 1951 | dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols) | |
| @@ -1937,6 +1937,15 @@ func (i *Interactive) redraw() { | |||||
| 1937 | i.sessionDialog.MaxRows = avail | 1937 | i.sessionDialog.MaxRows = avail | ||
| 1938 | dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols) | 1938 | dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols) | ||
| 1939 | case i.subagentsDialog.Active(): | 1939 | case i.subagentsDialog.Active(): | ||
| 1940 | + | // The dashboard is composed into the bottom-sticky dialog band. | |||
| 1941 | + | // Reserve its leading gap, frame padding, one chat row, and the | |||
| 1942 | + | // renderer margin so a long list or expanded snapshot never pushes | |||
| 1943 | + | // its top rows out of the terminal viewport. | |||
| 1944 | + | avail := rows - 4 | |||
| 1945 | + | if avail < 4 { | |||
| 1946 | + | avail = 4 | |||
| 1947 | + | } | |||
| 1948 | + | i.subagentsDialog.SetMaxRows(avail) | |||
| 1940 | dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols) | 1949 | dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols) | ||
| 1941 | case i.jumpDialog.Active(): | 1950 | case i.jumpDialog.Active(): | ||
| 1942 | dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols) | 1951 | dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols) | ||
M packages/agent/modes/subagents_dialog.go +187 -13
| @@ -21,7 +21,9 @@ import ( | |||
| 21 | 21 | // p send a follow-up prompt to the selected agent | |
| 22 | 22 | // R resume (restart) a detached / finished agent | |
| 23 | 23 | // ↑/↓ move cursor | |
| 24 | - | // enter show transcript tail for the selected agent | |
| 24 | + | // tab toggle this-session / all-sessions agents | |
| 25 | + | // ←/→ or h/l collapse / expand the selected agent's live snapshot | |
| 26 | + | // enter show the full transcript for the selected agent | |
| 25 | 27 | // k kill (Stop) the selected running agent | |
| 26 | 28 | // r remove a terminated agent (clears its state) | |
| 27 | 29 | // esc / q close | |
| @@ -32,12 +34,15 @@ import ( | |||
| 32 | 34 | // ↑/↓/PgUp/PgDn/Home/End scroll | |
| 33 | 35 | // esc / q return to the list | |
| 34 | 36 | type subagentsDialog struct { | |
| 35 | - | active bool | |
| 36 | - | compactMode bool | |
| 37 | - | lineInput bool | |
| 38 | - | snapshot func() []subagents.AgentSnapshot | |
| 39 | - | stop func(id string) error | |
| 40 | - | remove func(id string) error | |
| 37 | + | active bool | |
| 38 | + | compactMode bool | |
| 39 | + | lineInput bool | |
| 40 | + | snapshot func() []subagents.AgentSnapshot | |
| 41 | + | allSnapshots func() []subagents.AgentSnapshot | |
| 42 | + | showAll bool | |
| 43 | + | expandedID string | |
| 44 | + | stop func(id string) error | |
| 45 | + | remove func(id string) error | |
| 41 | 46 | // spawn accepts an optional model + provider override (empty | |
| 42 | 47 | // strings mean "let the child resolve its own default"). The cli | |
| 43 | 48 | // adapter forwards these to subagents.Supervisor.SpawnReq. | |
| @@ -55,6 +60,7 @@ type subagentsDialog struct { | |||
| 55 | 60 | ||
| 56 | 61 | rows []subagents.AgentSnapshot | |
| 57 | 62 | cursor int | |
| 63 | + | maxRows int // total dialog rows, including its header and frame rule; zero is unbounded | |
| 58 | 64 | viewing bool // when true, show full transcript of the selected row | |
| 59 | 65 | ||
| 60 | 66 | // transcriptSpin animates the busy hint shown above the inline | |
| @@ -126,6 +132,22 @@ func (d *subagentsDialog) SetLineInput(enabled bool) { | |||
| 126 | 132 | d.lineInput = enabled | |
| 127 | 133 | } | |
| 128 | 134 | ||
| 135 | + | // SetAllSnapshots supplies the cross-session snapshot source used when the | |
| 136 | + | // user presses Tab. Open's snapshot source remains the default session view. | |
| 137 | + | func (d *subagentsDialog) SetAllSnapshots(fn func() []subagents.AgentSnapshot) { | |
| 138 | + | d.allSnapshots = fn | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // SetMaxRows bounds the complete dashboard before the host composes its | |
| 142 | + | // bottom-sticky dialog band. Four rows are needed for the frame, column | |
| 143 | + | // heading, and selected agent; a non-positive value leaves it unbounded. | |
| 144 | + | func (d *subagentsDialog) SetMaxRows(rows int) { | |
| 145 | + | if rows > 0 && rows < 4 { | |
| 146 | + | rows = 4 | |
| 147 | + | } | |
| 148 | + | d.maxRows = rows | |
| 149 | + | } | |
| 150 | + | ||
| 129 | 151 | // SetLoadTranscript supplies the optional on-demand durable transcript loader. | |
| 130 | 152 | func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) { | |
| 131 | 153 | d.loadTranscript = fn | |
| @@ -180,6 +202,8 @@ func (d *subagentsDialog) Open( | |||
| 180 | 202 | d.active = true | |
| 181 | 203 | d.cursor = 0 | |
| 182 | 204 | d.viewing = false | |
| 205 | + | d.showAll = false | |
| 206 | + | d.expandedID = "" | |
| 183 | 207 | d.spawning = false | |
| 184 | 208 | d.newTaskEd = nil | |
| 185 | 209 | d.fileSuggest = nil | |
| @@ -467,17 +491,46 @@ func friendlySendErr(id string, err error) string { | |||
| 467 | 491 | } | |
| 468 | 492 | ||
| 469 | 493 | func (d *subagentsDialog) refresh() { | |
| 470 | - | if d.snapshot == nil { | |
| 494 | + | snapshot := d.snapshot | |
| 495 | + | if d.showAll && d.allSnapshots != nil { | |
| 496 | + | snapshot = d.allSnapshots | |
| 497 | + | } | |
| 498 | + | if snapshot == nil { | |
| 471 | 499 | d.rows = nil | |
| 472 | 500 | return | |
| 473 | 501 | } | |
| 474 | - | d.rows = d.snapshot() | |
| 502 | + | ||
| 503 | + | selectedID := "" | |
| 504 | + | if selected := d.selected(); selected != nil { | |
| 505 | + | selectedID = selected.ID | |
| 506 | + | } | |
| 507 | + | d.rows = snapshot() | |
| 508 | + | if selectedID != "" { | |
| 509 | + | for i, row := range d.rows { | |
| 510 | + | if row.ID == selectedID { | |
| 511 | + | d.cursor = i | |
| 512 | + | break | |
| 513 | + | } | |
| 514 | + | } | |
| 515 | + | } | |
| 475 | 516 | if d.cursor >= len(d.rows) { | |
| 476 | 517 | d.cursor = len(d.rows) - 1 | |
| 477 | 518 | } | |
| 478 | 519 | if d.cursor < 0 { | |
| 479 | 520 | d.cursor = 0 | |
| 480 | 521 | } | |
| 522 | + | if d.expandedID != "" && d.rowByID(d.expandedID) == nil { | |
| 523 | + | d.expandedID = "" | |
| 524 | + | } | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | func (d *subagentsDialog) rowByID(id string) *subagents.AgentSnapshot { | |
| 528 | + | for i := range d.rows { | |
| 529 | + | if d.rows[i].ID == id { | |
| 530 | + | return &d.rows[i] | |
| 531 | + | } | |
| 532 | + | } | |
| 533 | + | return nil | |
| 481 | 534 | } | |
| 482 | 535 | ||
| 483 | 536 | // HandleKey advances state. Returns (closed, statusMsg, statusErr). | |
| @@ -518,10 +571,22 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) | |||
| 518 | 571 | case tui.KeyUp: | |
| 519 | 572 | if d.cursor > 0 { | |
| 520 | 573 | d.cursor-- | |
| 574 | + | d.expandedID = "" | |
| 521 | 575 | } | |
| 522 | 576 | case tui.KeyDown: | |
| 523 | 577 | if d.cursor < len(d.rows)-1 { | |
| 524 | 578 | d.cursor++ | |
| 579 | + | d.expandedID = "" | |
| 580 | + | } | |
| 581 | + | case tui.KeyTab: | |
| 582 | + | d.showAll = !d.showAll | |
| 583 | + | d.expandedID = "" | |
| 584 | + | d.refresh() | |
| 585 | + | case tui.KeyLeft: | |
| 586 | + | d.collapseSelected() | |
| 587 | + | case tui.KeyRight: | |
| 588 | + | if err := d.expandSelected(); err != nil { | |
| 589 | + | return false, "", "load transcript: " + err.Error() | |
| 525 | 590 | } | |
| 526 | 591 | case tui.KeyEnter: | |
| 527 | 592 | if len(d.rows) > 0 { | |
| @@ -544,6 +609,12 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) | |||
| 544 | 609 | case 'q': | |
| 545 | 610 | d.Close() | |
| 546 | 611 | return true, "", "" | |
| 612 | + | case 'h': | |
| 613 | + | d.collapseSelected() | |
| 614 | + | case 'l': | |
| 615 | + | if err := d.expandSelected(); err != nil { | |
| 616 | + | return false, "", "load transcript: " + err.Error() | |
| 617 | + | } | |
| 547 | 618 | case 'n': | |
| 548 | 619 | if d.spawn != nil { | |
| 549 | 620 | // Open the task editor directly. The model the new | |
| @@ -604,6 +675,27 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) | |||
| 604 | 675 | return false, "", "" | |
| 605 | 676 | } | |
| 606 | 677 | ||
| 678 | + | func (d *subagentsDialog) expandSelected() error { | |
| 679 | + | a := d.selected() | |
| 680 | + | if a == nil { | |
| 681 | + | return nil | |
| 682 | + | } | |
| 683 | + | if d.loadTranscript != nil { | |
| 684 | + | if err := d.loadTranscript(a.ID); err != nil { | |
| 685 | + | return err | |
| 686 | + | } | |
| 687 | + | d.refresh() | |
| 688 | + | } | |
| 689 | + | d.expandedID = a.ID | |
| 690 | + | return nil | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | func (d *subagentsDialog) collapseSelected() { | |
| 694 | + | if a := d.selected(); a != nil && d.expandedID == a.ID { | |
| 695 | + | d.expandedID = "" | |
| 696 | + | } | |
| 697 | + | } | |
| 698 | + | ||
| 607 | 699 | // handleViewingKey routes one keystroke while the transcript view | |
| 608 | 700 | // is on screen. The transcript view runs /btw-style: an inline | |
| 609 | 701 | // editor is always pinned at the bottom, the transcript flows in | |
| @@ -1022,7 +1114,11 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string { | |||
| 1022 | 1114 | return d.renderTranscript(th, width) | |
| 1023 | 1115 | } | |
| 1024 | 1116 | ||
| 1025 | - | out := []string{frameHeader(th, "subagents (n new, p prompt, R resume, ↑/↓ move, enter view, k kill, r remove, esc close)", width)} | |
| 1117 | + | filter := "this session" | |
| 1118 | + | if d.showAll { | |
| 1119 | + | filter = "all sessions" | |
| 1120 | + | } | |
| 1121 | + | out := []string{frameHeader(th, "subagents · "+filter+" (tab filter, ↑/↓ select, ←/→ or h/l snapshot, enter view, esc close)", width)} | |
| 1026 | 1122 | if d.prompting { | |
| 1027 | 1123 | return d.renderPromptEditor(th, width, out) | |
| 1028 | 1124 | } | |
| @@ -1085,16 +1181,80 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string { | |||
| 1085 | 1181 | // Column header for readability. | |
| 1086 | 1182 | header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY") | |
| 1087 | 1183 | out = append(out, th.FGColor(th.Muted, header)) | |
| 1184 | + | out = append(out, d.renderVisibleRows(th, width)...) | |
| 1185 | + | out = append(out, frameRule(th, width)) | |
| 1186 | + | return out | |
| 1187 | + | } | |
| 1088 | 1188 | ||
| 1089 | - | for i, r := range d.rows { | |
| 1189 | + | // renderVisibleRows keeps the dashboard inside the terminal-height budget. | |
| 1190 | + | // The selected row is always rendered, which prevents a bottom-sticky dialog | |
| 1191 | + | // from clipping the top rows (and leaving arrow-key selection invisible). | |
| 1192 | + | func (d *subagentsDialog) renderVisibleRows(th tui.Theme, width int) []string { | |
| 1193 | + | budget := len(d.rows) * (dashboardSnapshotRows + 1) | |
| 1194 | + | if d.maxRows > 0 { | |
| 1195 | + | budget = d.maxRows - 3 // frame header, column heading, frame rule | |
| 1196 | + | if budget < 1 { | |
| 1197 | + | budget = 1 | |
| 1198 | + | } | |
| 1199 | + | } | |
| 1200 | + | ||
| 1201 | + | out := make([]string, 0, budget) | |
| 1202 | + | // Do not spend the only available body row on the indicator: the selected | |
| 1203 | + | // agent must remain visible even on an extremely short terminal. | |
| 1204 | + | if d.cursor > 0 && budget > 1 { | |
| 1205 | + | out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("↑ %d agents above", d.cursor))) | |
| 1206 | + | } | |
| 1207 | + | nextRow := d.cursor | |
| 1208 | + | for ; nextRow < len(d.rows) && len(out) < budget; nextRow++ { | |
| 1209 | + | r := d.rows[nextRow] | |
| 1090 | 1210 | row := formatSupervisorRow(r, width-2) | |
| 1091 | - | if i == d.cursor { | |
| 1211 | + | if nextRow == d.cursor { | |
| 1092 | 1212 | out = append(out, th.PadHighlight(" "+row, width)) | |
| 1093 | 1213 | } else { | |
| 1094 | 1214 | out = append(out, " "+th.FGColor(th.FG, row)) | |
| 1095 | 1215 | } | |
| 1216 | + | if r.ID == d.expandedID && len(out) < budget { | |
| 1217 | + | out = append(out, d.renderSnapshot(th, width, r, budget-len(out))...) | |
| 1218 | + | } | |
| 1219 | + | } | |
| 1220 | + | if nextRow < len(d.rows) && len(out) < budget { | |
| 1221 | + | out = append(out, " "+th.FGColor(th.Muted, "↓ more agents below")) | |
| 1222 | + | } | |
| 1223 | + | return out | |
| 1224 | + | } | |
| 1225 | + | ||
| 1226 | + | // dashboardSnapshotRows is deliberately bounded so expanding a running agent | |
| 1227 | + | // keeps the list usable while still exposing its current user, assistant, and | |
| 1228 | + | // tool activity. Enter opens the complete transcript. | |
| 1229 | + | const dashboardSnapshotRows = 12 | |
| 1230 | + | ||
| 1231 | + | func (d *subagentsDialog) renderSnapshot(th tui.Theme, width int, a subagents.AgentSnapshot, remaining int) []string { | |
| 1232 | + | raw := a.Lines | |
| 1233 | + | if len(raw) == 0 && a.Tail != "" { | |
| 1234 | + | raw = strings.Split(a.Tail, "\n") | |
| 1235 | + | } | |
| 1236 | + | out := []string{" " + th.FGColor(th.Muted, "└─ live snapshot")} | |
| 1237 | + | if len(raw) == 0 || remaining <= 1 { | |
| 1238 | + | if remaining > 1 { | |
| 1239 | + | out = append(out, " "+th.FGColor(th.Muted, " (no activity yet)")) | |
| 1240 | + | } | |
| 1241 | + | return out | |
| 1242 | + | } | |
| 1243 | + | rows := renderSupervisorTranscriptBlocks(raw, th, width-4, d.compactMode) | |
| 1244 | + | limit := dashboardSnapshotRows | |
| 1245 | + | if limit > remaining-1 { | |
| 1246 | + | limit = remaining - 1 | |
| 1247 | + | } | |
| 1248 | + | if len(rows) > limit { | |
| 1249 | + | rows = rows[len(rows)-limit:] | |
| 1250 | + | if limit > 1 { | |
| 1251 | + | out = append(out, " "+th.FGColor(th.Muted, " … earlier activity")) | |
| 1252 | + | rows = rows[1:] | |
| 1253 | + | } | |
| 1254 | + | } | |
| 1255 | + | for _, row := range rows { | |
| 1256 | + | out = append(out, " "+row) | |
| 1096 | 1257 | } | |
| 1097 | - | out = append(out, frameRule(th, width)) | |
| 1098 | 1258 | return out | |
| 1099 | 1259 | } | |
| 1100 | 1260 | ||
| @@ -1273,6 +1433,8 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c | |||
| 1273 | 1433 | const ( | |
| 1274 | 1434 | kindAssistant blockKind = iota | |
| 1275 | 1435 | kindUser | |
| 1436 | + | kindTool | |
| 1437 | + | kindToolResult | |
| 1276 | 1438 | kindStderr | |
| 1277 | 1439 | kindError | |
| 1278 | 1440 | ) | |
| @@ -1286,6 +1448,10 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c | |||
| 1286 | 1448 | switch { | |
| 1287 | 1449 | case strings.HasPrefix(s, "user: "): | |
| 1288 | 1450 | return kindUser, strings.TrimPrefix(s, "user: ") | |
| 1451 | + | case strings.HasPrefix(s, "tool: "): | |
| 1452 | + | return kindTool, strings.TrimPrefix(s, "tool: ") | |
| 1453 | + | case strings.HasPrefix(s, "tool result: "): | |
| 1454 | + | return kindToolResult, strings.TrimPrefix(s, "tool result: ") | |
| 1289 | 1455 | case strings.HasPrefix(s, "stderr: "): | |
| 1290 | 1456 | return kindStderr, strings.TrimPrefix(s, "stderr: ") | |
| 1291 | 1457 | case strings.HasPrefix(s, "error: "): | |
| @@ -1339,6 +1505,14 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c | |||
| 1339 | 1505 | case kindAssistant: | |
| 1340 | 1506 | text := strings.Join(b.body, "\n") | |
| 1341 | 1507 | out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...) | |
| 1508 | + | case kindTool: | |
| 1509 | + | for _, line := range b.body { | |
| 1510 | + | out = append(out, wrapDialogTextRows(th.FGColor(th.Accent, "⚙ "+line), width)...) | |
| 1511 | + | } | |
| 1512 | + | case kindToolResult: | |
| 1513 | + | for _, line := range b.body { | |
| 1514 | + | out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "↳ "+line), width)...) | |
| 1515 | + | } | |
| 1342 | 1516 | case kindStderr: | |
| 1343 | 1517 | for _, line := range b.body { | |
| 1344 | 1518 | out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "stderr "+line), width)...) | |
| @@ -21,7 +21,9 @@ import ( | |||||
| 21 | // p send a follow-up prompt to the selected agent | 21 | // p send a follow-up prompt to the selected agent | ||
| 22 | // R resume (restart) a detached / finished agent | 22 | // R resume (restart) a detached / finished agent | ||
| 23 | // ↑/↓ move cursor | 23 | // ↑/↓ move cursor | ||
| 24 | - | // enter show transcript tail for the selected agent | 24 | + | // tab toggle this-session / all-sessions agents |
| 25 | + | // ←/→ or h/l collapse / expand the selected agent's live snapshot | |||
| 26 | + | // enter show the full transcript for the selected agent | |||
| 25 | // k kill (Stop) the selected running agent | 27 | // k kill (Stop) the selected running agent | ||
| 26 | // r remove a terminated agent (clears its state) | 28 | // r remove a terminated agent (clears its state) | ||
| 27 | // esc / q close | 29 | // esc / q close | ||
| @@ -32,12 +34,15 @@ import ( | |||||
| 32 | // ↑/↓/PgUp/PgDn/Home/End scroll | 34 | // ↑/↓/PgUp/PgDn/Home/End scroll | ||
| 33 | // esc / q return to the list | 35 | // esc / q return to the list | ||
| 34 | type subagentsDialog struct { | 36 | type subagentsDialog struct { | ||
| 35 | - | active bool | 37 | + | active bool |
| 36 | - | compactMode bool | 38 | + | compactMode bool |
| 37 | - | lineInput bool | 39 | + | lineInput bool |
| 38 | - | snapshot func() []subagents.AgentSnapshot | 40 | + | snapshot func() []subagents.AgentSnapshot |
| 39 | - | stop func(id string) error | 41 | + | allSnapshots func() []subagents.AgentSnapshot |
| 40 | - | remove func(id string) error | 42 | + | showAll bool |
| 43 | + | expandedID string | |||
| 44 | + | stop func(id string) error | |||
| 45 | + | remove func(id string) error | |||
| 41 | // spawn accepts an optional model + provider override (empty | 46 | // spawn accepts an optional model + provider override (empty | ||
| 42 | // strings mean "let the child resolve its own default"). The cli | 47 | // strings mean "let the child resolve its own default"). The cli | ||
| 43 | // adapter forwards these to subagents.Supervisor.SpawnReq. | 48 | // adapter forwards these to subagents.Supervisor.SpawnReq. | ||
| @@ -55,6 +60,7 @@ type subagentsDialog struct { | |||||
| 55 | 60 | ||||
| 56 | rows []subagents.AgentSnapshot | 61 | rows []subagents.AgentSnapshot | ||
| 57 | cursor int | 62 | cursor int | ||
| 63 | + | maxRows int // total dialog rows, including its header and frame rule; zero is unbounded | |||
| 58 | viewing bool // when true, show full transcript of the selected row | 64 | viewing bool // when true, show full transcript of the selected row | ||
| 59 | 65 | ||||
| 60 | // transcriptSpin animates the busy hint shown above the inline | 66 | // transcriptSpin animates the busy hint shown above the inline | ||
| @@ -126,6 +132,22 @@ func (d *subagentsDialog) SetLineInput(enabled bool) { | |||||
| 126 | d.lineInput = enabled | 132 | d.lineInput = enabled | ||
| 127 | } | 133 | } | ||
| 128 | 134 | ||||
| 135 | + | // SetAllSnapshots supplies the cross-session snapshot source used when the | |||
| 136 | + | // user presses Tab. Open's snapshot source remains the default session view. | |||
| 137 | + | func (d *subagentsDialog) SetAllSnapshots(fn func() []subagents.AgentSnapshot) { | |||
| 138 | + | d.allSnapshots = fn | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // SetMaxRows bounds the complete dashboard before the host composes its | |||
| 142 | + | // bottom-sticky dialog band. Four rows are needed for the frame, column | |||
| 143 | + | // heading, and selected agent; a non-positive value leaves it unbounded. | |||
| 144 | + | func (d *subagentsDialog) SetMaxRows(rows int) { | |||
| 145 | + | if rows > 0 && rows < 4 { | |||
| 146 | + | rows = 4 | |||
| 147 | + | } | |||
| 148 | + | d.maxRows = rows | |||
| 149 | + | } | |||
| 150 | + | ||||
| 129 | // SetLoadTranscript supplies the optional on-demand durable transcript loader. | 151 | // SetLoadTranscript supplies the optional on-demand durable transcript loader. | ||
| 130 | func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) { | 152 | func (d *subagentsDialog) SetLoadTranscript(fn func(id string) error) { | ||
| 131 | d.loadTranscript = fn | 153 | d.loadTranscript = fn | ||
| @@ -180,6 +202,8 @@ func (d *subagentsDialog) Open( | |||||
| 180 | d.active = true | 202 | d.active = true | ||
| 181 | d.cursor = 0 | 203 | d.cursor = 0 | ||
| 182 | d.viewing = false | 204 | d.viewing = false | ||
| 205 | + | d.showAll = false | |||
| 206 | + | d.expandedID = "" | |||
| 183 | d.spawning = false | 207 | d.spawning = false | ||
| 184 | d.newTaskEd = nil | 208 | d.newTaskEd = nil | ||
| 185 | d.fileSuggest = nil | 209 | d.fileSuggest = nil | ||
| @@ -467,17 +491,46 @@ func friendlySendErr(id string, err error) string { | |||||
| 467 | } | 491 | } | ||
| 468 | 492 | ||||
| 469 | func (d *subagentsDialog) refresh() { | 493 | func (d *subagentsDialog) refresh() { | ||
| 470 | - | if d.snapshot == nil { | 494 | + | snapshot := d.snapshot |
| 495 | + | if d.showAll && d.allSnapshots != nil { | |||
| 496 | + | snapshot = d.allSnapshots | |||
| 497 | + | } | |||
| 498 | + | if snapshot == nil { | |||
| 471 | d.rows = nil | 499 | d.rows = nil | ||
| 472 | return | 500 | return | ||
| 473 | } | 501 | } | ||
| 474 | - | d.rows = d.snapshot() | 502 | + | |
| 503 | + | selectedID := "" | |||
| 504 | + | if selected := d.selected(); selected != nil { | |||
| 505 | + | selectedID = selected.ID | |||
| 506 | + | } | |||
| 507 | + | d.rows = snapshot() | |||
| 508 | + | if selectedID != "" { | |||
| 509 | + | for i, row := range d.rows { | |||
| 510 | + | if row.ID == selectedID { | |||
| 511 | + | d.cursor = i | |||
| 512 | + | break | |||
| 513 | + | } | |||
| 514 | + | } | |||
| 515 | + | } | |||
| 475 | if d.cursor >= len(d.rows) { | 516 | if d.cursor >= len(d.rows) { | ||
| 476 | d.cursor = len(d.rows) - 1 | 517 | d.cursor = len(d.rows) - 1 | ||
| 477 | } | 518 | } | ||
| 478 | if d.cursor < 0 { | 519 | if d.cursor < 0 { | ||
| 479 | d.cursor = 0 | 520 | d.cursor = 0 | ||
| 480 | } | 521 | } | ||
| 522 | + | if d.expandedID != "" && d.rowByID(d.expandedID) == nil { | |||
| 523 | + | d.expandedID = "" | |||
| 524 | + | } | |||
| 525 | + | } | |||
| 526 | + | ||||
| 527 | + | func (d *subagentsDialog) rowByID(id string) *subagents.AgentSnapshot { | |||
| 528 | + | for i := range d.rows { | |||
| 529 | + | if d.rows[i].ID == id { | |||
| 530 | + | return &d.rows[i] | |||
| 531 | + | } | |||
| 532 | + | } | |||
| 533 | + | return nil | |||
| 481 | } | 534 | } | ||
| 482 | 535 | ||||
| 483 | // HandleKey advances state. Returns (closed, statusMsg, statusErr). | 536 | // HandleKey advances state. Returns (closed, statusMsg, statusErr). | ||
| @@ -518,10 +571,22 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) | |||||
| 518 | case tui.KeyUp: | 571 | case tui.KeyUp: | ||
| 519 | if d.cursor > 0 { | 572 | if d.cursor > 0 { | ||
| 520 | d.cursor-- | 573 | d.cursor-- | ||
| 574 | + | d.expandedID = "" | |||
| 521 | } | 575 | } | ||
| 522 | case tui.KeyDown: | 576 | case tui.KeyDown: | ||
| 523 | if d.cursor < len(d.rows)-1 { | 577 | if d.cursor < len(d.rows)-1 { | ||
| 524 | d.cursor++ | 578 | d.cursor++ | ||
| 579 | + | d.expandedID = "" | |||
| 580 | + | } | |||
| 581 | + | case tui.KeyTab: | |||
| 582 | + | d.showAll = !d.showAll | |||
| 583 | + | d.expandedID = "" | |||
| 584 | + | d.refresh() | |||
| 585 | + | case tui.KeyLeft: | |||
| 586 | + | d.collapseSelected() | |||
| 587 | + | case tui.KeyRight: | |||
| 588 | + | if err := d.expandSelected(); err != nil { | |||
| 589 | + | return false, "", "load transcript: " + err.Error() | |||
| 525 | } | 590 | } | ||
| 526 | case tui.KeyEnter: | 591 | case tui.KeyEnter: | ||
| 527 | if len(d.rows) > 0 { | 592 | if len(d.rows) > 0 { | ||
| @@ -544,6 +609,12 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) | |||||
| 544 | case 'q': | 609 | case 'q': | ||
| 545 | d.Close() | 610 | d.Close() | ||
| 546 | return true, "", "" | 611 | return true, "", "" | ||
| 612 | + | case 'h': | |||
| 613 | + | d.collapseSelected() | |||
| 614 | + | case 'l': | |||
| 615 | + | if err := d.expandSelected(); err != nil { | |||
| 616 | + | return false, "", "load transcript: " + err.Error() | |||
| 617 | + | } | |||
| 547 | case 'n': | 618 | case 'n': | ||
| 548 | if d.spawn != nil { | 619 | if d.spawn != nil { | ||
| 549 | // Open the task editor directly. The model the new | 620 | // Open the task editor directly. The model the new | ||
| @@ -604,6 +675,27 @@ func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) | |||||
| 604 | return false, "", "" | 675 | return false, "", "" | ||
| 605 | } | 676 | } | ||
| 606 | 677 | ||||
| 678 | + | func (d *subagentsDialog) expandSelected() error { | |||
| 679 | + | a := d.selected() | |||
| 680 | + | if a == nil { | |||
| 681 | + | return nil | |||
| 682 | + | } | |||
| 683 | + | if d.loadTranscript != nil { | |||
| 684 | + | if err := d.loadTranscript(a.ID); err != nil { | |||
| 685 | + | return err | |||
| 686 | + | } | |||
| 687 | + | d.refresh() | |||
| 688 | + | } | |||
| 689 | + | d.expandedID = a.ID | |||
| 690 | + | return nil | |||
| 691 | + | } | |||
| 692 | + | ||||
| 693 | + | func (d *subagentsDialog) collapseSelected() { | |||
| 694 | + | if a := d.selected(); a != nil && d.expandedID == a.ID { | |||
| 695 | + | d.expandedID = "" | |||
| 696 | + | } | |||
| 697 | + | } | |||
| 698 | + | ||||
| 607 | // handleViewingKey routes one keystroke while the transcript view | 699 | // handleViewingKey routes one keystroke while the transcript view | ||
| 608 | // is on screen. The transcript view runs /btw-style: an inline | 700 | // is on screen. The transcript view runs /btw-style: an inline | ||
| 609 | // editor is always pinned at the bottom, the transcript flows in | 701 | // editor is always pinned at the bottom, the transcript flows in | ||
| @@ -1022,7 +1114,11 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string { | |||||
| 1022 | return d.renderTranscript(th, width) | 1114 | return d.renderTranscript(th, width) | ||
| 1023 | } | 1115 | } | ||
| 1024 | 1116 | ||||
| 1025 | - | out := []string{frameHeader(th, "subagents (n new, p prompt, R resume, ↑/↓ move, enter view, k kill, r remove, esc close)", width)} | 1117 | + | filter := "this session" |
| 1118 | + | if d.showAll { | |||
| 1119 | + | filter = "all sessions" | |||
| 1120 | + | } | |||
| 1121 | + | out := []string{frameHeader(th, "subagents · "+filter+" (tab filter, ↑/↓ select, ←/→ or h/l snapshot, enter view, esc close)", width)} | |||
| 1026 | if d.prompting { | 1122 | if d.prompting { | ||
| 1027 | return d.renderPromptEditor(th, width, out) | 1123 | return d.renderPromptEditor(th, width, out) | ||
| 1028 | } | 1124 | } | ||
| @@ -1085,16 +1181,80 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string { | |||||
| 1085 | // Column header for readability. | 1181 | // Column header for readability. | ||
| 1086 | header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY") | 1182 | header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY") | ||
| 1087 | out = append(out, th.FGColor(th.Muted, header)) | 1183 | out = append(out, th.FGColor(th.Muted, header)) | ||
| 1184 | + | out = append(out, d.renderVisibleRows(th, width)...) | |||
| 1185 | + | out = append(out, frameRule(th, width)) | |||
| 1186 | + | return out | |||
| 1187 | + | } | |||
| 1088 | 1188 | ||||
| 1089 | - | for i, r := range d.rows { | 1189 | + | // renderVisibleRows keeps the dashboard inside the terminal-height budget. |
| 1190 | + | // The selected row is always rendered, which prevents a bottom-sticky dialog | |||
| 1191 | + | // from clipping the top rows (and leaving arrow-key selection invisible). | |||
| 1192 | + | func (d *subagentsDialog) renderVisibleRows(th tui.Theme, width int) []string { | |||
| 1193 | + | budget := len(d.rows) * (dashboardSnapshotRows + 1) | |||
| 1194 | + | if d.maxRows > 0 { | |||
| 1195 | + | budget = d.maxRows - 3 // frame header, column heading, frame rule | |||
| 1196 | + | if budget < 1 { | |||
| 1197 | + | budget = 1 | |||
| 1198 | + | } | |||
| 1199 | + | } | |||
| 1200 | + | ||||
| 1201 | + | out := make([]string, 0, budget) | |||
| 1202 | + | // Do not spend the only available body row on the indicator: the selected | |||
| 1203 | + | // agent must remain visible even on an extremely short terminal. | |||
| 1204 | + | if d.cursor > 0 && budget > 1 { | |||
| 1205 | + | out = append(out, " "+th.FGColor(th.Muted, fmt.Sprintf("↑ %d agents above", d.cursor))) | |||
| 1206 | + | } | |||
| 1207 | + | nextRow := d.cursor | |||
| 1208 | + | for ; nextRow < len(d.rows) && len(out) < budget; nextRow++ { | |||
| 1209 | + | r := d.rows[nextRow] | |||
| 1090 | row := formatSupervisorRow(r, width-2) | 1210 | row := formatSupervisorRow(r, width-2) | ||
| 1091 | - | if i == d.cursor { | 1211 | + | if nextRow == d.cursor { |
| 1092 | out = append(out, th.PadHighlight(" "+row, width)) | 1212 | out = append(out, th.PadHighlight(" "+row, width)) | ||
| 1093 | } else { | 1213 | } else { | ||
| 1094 | out = append(out, " "+th.FGColor(th.FG, row)) | 1214 | out = append(out, " "+th.FGColor(th.FG, row)) | ||
| 1095 | } | 1215 | } | ||
| 1216 | + | if r.ID == d.expandedID && len(out) < budget { | |||
| 1217 | + | out = append(out, d.renderSnapshot(th, width, r, budget-len(out))...) | |||
| 1218 | + | } | |||
| 1219 | + | } | |||
| 1220 | + | if nextRow < len(d.rows) && len(out) < budget { | |||
| 1221 | + | out = append(out, " "+th.FGColor(th.Muted, "↓ more agents below")) | |||
| 1222 | + | } | |||
| 1223 | + | return out | |||
| 1224 | + | } | |||
| 1225 | + | ||||
| 1226 | + | // dashboardSnapshotRows is deliberately bounded so expanding a running agent | |||
| 1227 | + | // keeps the list usable while still exposing its current user, assistant, and | |||
| 1228 | + | // tool activity. Enter opens the complete transcript. | |||
| 1229 | + | const dashboardSnapshotRows = 12 | |||
| 1230 | + | ||||
| 1231 | + | func (d *subagentsDialog) renderSnapshot(th tui.Theme, width int, a subagents.AgentSnapshot, remaining int) []string { | |||
| 1232 | + | raw := a.Lines | |||
| 1233 | + | if len(raw) == 0 && a.Tail != "" { | |||
| 1234 | + | raw = strings.Split(a.Tail, "\n") | |||
| 1235 | + | } | |||
| 1236 | + | out := []string{" " + th.FGColor(th.Muted, "└─ live snapshot")} | |||
| 1237 | + | if len(raw) == 0 || remaining <= 1 { | |||
| 1238 | + | if remaining > 1 { | |||
| 1239 | + | out = append(out, " "+th.FGColor(th.Muted, " (no activity yet)")) | |||
| 1240 | + | } | |||
| 1241 | + | return out | |||
| 1242 | + | } | |||
| 1243 | + | rows := renderSupervisorTranscriptBlocks(raw, th, width-4, d.compactMode) | |||
| 1244 | + | limit := dashboardSnapshotRows | |||
| 1245 | + | if limit > remaining-1 { | |||
| 1246 | + | limit = remaining - 1 | |||
| 1247 | + | } | |||
| 1248 | + | if len(rows) > limit { | |||
| 1249 | + | rows = rows[len(rows)-limit:] | |||
| 1250 | + | if limit > 1 { | |||
| 1251 | + | out = append(out, " "+th.FGColor(th.Muted, " … earlier activity")) | |||
| 1252 | + | rows = rows[1:] | |||
| 1253 | + | } | |||
| 1254 | + | } | |||
| 1255 | + | for _, row := range rows { | |||
| 1256 | + | out = append(out, " "+row) | |||
| 1096 | } | 1257 | } | ||
| 1097 | - | out = append(out, frameRule(th, width)) | |||
| 1098 | return out | 1258 | return out | ||
| 1099 | } | 1259 | } | ||
| 1100 | 1260 | ||||
| @@ -1273,6 +1433,8 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c | |||||
| 1273 | const ( | 1433 | const ( | ||
| 1274 | kindAssistant blockKind = iota | 1434 | kindAssistant blockKind = iota | ||
| 1275 | kindUser | 1435 | kindUser | ||
| 1436 | + | kindTool | |||
| 1437 | + | kindToolResult | |||
| 1276 | kindStderr | 1438 | kindStderr | ||
| 1277 | kindError | 1439 | kindError | ||
| 1278 | ) | 1440 | ) | ||
| @@ -1286,6 +1448,10 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c | |||||
| 1286 | switch { | 1448 | switch { | ||
| 1287 | case strings.HasPrefix(s, "user: "): | 1449 | case strings.HasPrefix(s, "user: "): | ||
| 1288 | return kindUser, strings.TrimPrefix(s, "user: ") | 1450 | return kindUser, strings.TrimPrefix(s, "user: ") | ||
| 1451 | + | case strings.HasPrefix(s, "tool: "): | |||
| 1452 | + | return kindTool, strings.TrimPrefix(s, "tool: ") | |||
| 1453 | + | case strings.HasPrefix(s, "tool result: "): | |||
| 1454 | + | return kindToolResult, strings.TrimPrefix(s, "tool result: ") | |||
| 1289 | case strings.HasPrefix(s, "stderr: "): | 1455 | case strings.HasPrefix(s, "stderr: "): | ||
| 1290 | return kindStderr, strings.TrimPrefix(s, "stderr: ") | 1456 | return kindStderr, strings.TrimPrefix(s, "stderr: ") | ||
| 1291 | case strings.HasPrefix(s, "error: "): | 1457 | case strings.HasPrefix(s, "error: "): | ||
| @@ -1339,6 +1505,14 @@ func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, c | |||||
| 1339 | case kindAssistant: | 1505 | case kindAssistant: | ||
| 1340 | text := strings.Join(b.body, "\n") | 1506 | text := strings.Join(b.body, "\n") | ||
| 1341 | out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...) | 1507 | out = append(out, renderDialogMarkdownRows(strings.TrimLeft(text, "\n"), th, width)...) | ||
| 1508 | + | case kindTool: | |||
| 1509 | + | for _, line := range b.body { | |||
| 1510 | + | out = append(out, wrapDialogTextRows(th.FGColor(th.Accent, "⚙ "+line), width)...) | |||
| 1511 | + | } | |||
| 1512 | + | case kindToolResult: | |||
| 1513 | + | for _, line := range b.body { | |||
| 1514 | + | out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "↳ "+line), width)...) | |||
| 1515 | + | } | |||
| 1342 | case kindStderr: | 1516 | case kindStderr: | ||
| 1343 | for _, line := range b.body { | 1517 | for _, line := range b.body { | ||
| 1344 | out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "stderr "+line), width)...) | 1518 | out = append(out, wrapDialogTextRows(th.FGColor(th.Muted, "stderr "+line), width)...) | ||
M packages/agent/modes/subagents_dialog_test.go +72 -0
| @@ -1319,6 +1319,78 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) { | |||
| 1319 | 1319 | } | |
| 1320 | 1320 | } | |
| 1321 | 1321 | ||
| 1322 | + | func TestSubagentsDialogTabTogglesSessionScope(t *testing.T) { | |
| 1323 | + | now := time.Now() | |
| 1324 | + | current := staticSnapshots( | |
| 1325 | + | subagents.AgentSnapshot{ID: "current-1", Status: subagents.StatusRunning, Started: now}, | |
| 1326 | + | subagents.AgentSnapshot{ID: "current-2", Status: subagents.StatusDone, Started: now}, | |
| 1327 | + | ) | |
| 1328 | + | all := staticSnapshots( | |
| 1329 | + | subagents.AgentSnapshot{ID: "current-1", Status: subagents.StatusRunning, Started: now}, | |
| 1330 | + | subagents.AgentSnapshot{ID: "current-2", Status: subagents.StatusDone, Started: now}, | |
| 1331 | + | subagents.AgentSnapshot{ID: "other-session", Status: subagents.StatusDone, Started: now}, | |
| 1332 | + | ) | |
| 1333 | + | d := newSubagentsDialog() | |
| 1334 | + | d.SetAllSnapshots(all) | |
| 1335 | + | d.Open(current, nil, nil, nil, nil, nil, "") | |
| 1336 | + | ||
| 1337 | + | out := strings.Join(d.Render(tui.Theme{}, 100), "\n") | |
| 1338 | + | if strings.Contains(out, "other-session") || !strings.Contains(out, "this session") { | |
| 1339 | + | t.Fatalf("default view is not restricted to the current session:\n%s", out) | |
| 1340 | + | } | |
| 1341 | + | ||
| 1342 | + | d.HandleKey(tui.Key{Kind: tui.KeyTab}) | |
| 1343 | + | out = strings.Join(d.Render(tui.Theme{}, 100), "\n") | |
| 1344 | + | if !strings.Contains(out, "other-session") || !strings.Contains(out, "all sessions") { | |
| 1345 | + | t.Fatalf("Tab did not switch to the all-sessions view:\n%s", out) | |
| 1346 | + | } | |
| 1347 | + | } | |
| 1348 | + | ||
| 1349 | + | func TestSubagentsDialogNavigatesAndExpandsLiveSnapshotWithinHeight(t *testing.T) { | |
| 1350 | + | now := time.Now() | |
| 1351 | + | rows := []subagents.AgentSnapshot{ | |
| 1352 | + | {ID: "first", Status: subagents.StatusDone, Started: now}, | |
| 1353 | + | {ID: "active", Status: subagents.StatusRunning, Started: now, Lines: []string{ | |
| 1354 | + | "user: inspect the dashboard", "assistant: I am checking the list", "tool: bash", "tool result: dashboard source", "assistant: rendering snapshot", | |
| 1355 | + | }}, | |
| 1356 | + | {ID: "third", Status: subagents.StatusDone, Started: now}, | |
| 1357 | + | } | |
| 1358 | + | d := newSubagentsDialog() | |
| 1359 | + | d.SetMaxRows(20) | |
| 1360 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |
| 1361 | + | _ = d.Render(tui.Theme{}, 100) | |
| 1362 | + | ||
| 1363 | + | d.HandleKey(tui.Key{Kind: tui.KeyDown}) | |
| 1364 | + | if selected := d.selected(); selected == nil || selected.ID != "active" { | |
| 1365 | + | t.Fatalf("down did not select active agent: %#v", selected) | |
| 1366 | + | } | |
| 1367 | + | d.HandleKey(tui.Key{Kind: tui.KeyRight}) | |
| 1368 | + | out := d.Render(tui.Theme{}, 100) | |
| 1369 | + | joined := strings.Join(out, "\n") | |
| 1370 | + | for _, want := range []string{"live snapshot", "inspect the dashboard", "bash", "dashboard source"} { | |
| 1371 | + | if !strings.Contains(joined, want) { | |
| 1372 | + | t.Errorf("expanded snapshot missing %q:\n%s", want, joined) | |
| 1373 | + | } | |
| 1374 | + | } | |
| 1375 | + | if len(out) > 20 { | |
| 1376 | + | t.Fatalf("dashboard rendered %d rows with 20-row budget:\n%s", len(out), joined) | |
| 1377 | + | } | |
| 1378 | + | ||
| 1379 | + | d.HandleKey(tui.Key{Kind: tui.KeyLeft}) | |
| 1380 | + | out = d.Render(tui.Theme{}, 100) | |
| 1381 | + | if strings.Contains(strings.Join(out, "\n"), "live snapshot") { | |
| 1382 | + | t.Fatalf("left did not collapse selected snapshot:\n%s", strings.Join(out, "\n")) | |
| 1383 | + | } | |
| 1384 | + | ||
| 1385 | + | // Even the smallest supported dashboard retains the selected row instead | |
| 1386 | + | // of allowing the bottom-sticky compositor to clip it above the viewport. | |
| 1387 | + | d.SetMaxRows(4) | |
| 1388 | + | out = d.Render(tui.Theme{}, 100) | |
| 1389 | + | if len(out) != 4 || !strings.Contains(strings.Join(out, "\n"), "active") { | |
| 1390 | + | t.Fatalf("short dashboard lost its selected row (%d rows):\n%s", len(out), strings.Join(out, "\n")) | |
| 1391 | + | } | |
| 1392 | + | } | |
| 1393 | + | ||
| 1322 | 1394 | func TestStatusLabelCoversAll(t *testing.T) { | |
| 1323 | 1395 | for _, s := range []subagents.Status{ | |
| 1324 | 1396 | subagents.StatusPending, subagents.StatusRunning, | |
| @@ -1319,6 +1319,78 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) { | |||||
| 1319 | } | 1319 | } | ||
| 1320 | } | 1320 | } | ||
| 1321 | 1321 | ||||
| 1322 | + | func TestSubagentsDialogTabTogglesSessionScope(t *testing.T) { | |||
| 1323 | + | now := time.Now() | |||
| 1324 | + | current := staticSnapshots( | |||
| 1325 | + | subagents.AgentSnapshot{ID: "current-1", Status: subagents.StatusRunning, Started: now}, | |||
| 1326 | + | subagents.AgentSnapshot{ID: "current-2", Status: subagents.StatusDone, Started: now}, | |||
| 1327 | + | ) | |||
| 1328 | + | all := staticSnapshots( | |||
| 1329 | + | subagents.AgentSnapshot{ID: "current-1", Status: subagents.StatusRunning, Started: now}, | |||
| 1330 | + | subagents.AgentSnapshot{ID: "current-2", Status: subagents.StatusDone, Started: now}, | |||
| 1331 | + | subagents.AgentSnapshot{ID: "other-session", Status: subagents.StatusDone, Started: now}, | |||
| 1332 | + | ) | |||
| 1333 | + | d := newSubagentsDialog() | |||
| 1334 | + | d.SetAllSnapshots(all) | |||
| 1335 | + | d.Open(current, nil, nil, nil, nil, nil, "") | |||
| 1336 | + | ||||
| 1337 | + | out := strings.Join(d.Render(tui.Theme{}, 100), "\n") | |||
| 1338 | + | if strings.Contains(out, "other-session") || !strings.Contains(out, "this session") { | |||
| 1339 | + | t.Fatalf("default view is not restricted to the current session:\n%s", out) | |||
| 1340 | + | } | |||
| 1341 | + | ||||
| 1342 | + | d.HandleKey(tui.Key{Kind: tui.KeyTab}) | |||
| 1343 | + | out = strings.Join(d.Render(tui.Theme{}, 100), "\n") | |||
| 1344 | + | if !strings.Contains(out, "other-session") || !strings.Contains(out, "all sessions") { | |||
| 1345 | + | t.Fatalf("Tab did not switch to the all-sessions view:\n%s", out) | |||
| 1346 | + | } | |||
| 1347 | + | } | |||
| 1348 | + | ||||
| 1349 | + | func TestSubagentsDialogNavigatesAndExpandsLiveSnapshotWithinHeight(t *testing.T) { | |||
| 1350 | + | now := time.Now() | |||
| 1351 | + | rows := []subagents.AgentSnapshot{ | |||
| 1352 | + | {ID: "first", Status: subagents.StatusDone, Started: now}, | |||
| 1353 | + | {ID: "active", Status: subagents.StatusRunning, Started: now, Lines: []string{ | |||
| 1354 | + | "user: inspect the dashboard", "assistant: I am checking the list", "tool: bash", "tool result: dashboard source", "assistant: rendering snapshot", | |||
| 1355 | + | }}, | |||
| 1356 | + | {ID: "third", Status: subagents.StatusDone, Started: now}, | |||
| 1357 | + | } | |||
| 1358 | + | d := newSubagentsDialog() | |||
| 1359 | + | d.SetMaxRows(20) | |||
| 1360 | + | d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") | |||
| 1361 | + | _ = d.Render(tui.Theme{}, 100) | |||
| 1362 | + | ||||
| 1363 | + | d.HandleKey(tui.Key{Kind: tui.KeyDown}) | |||
| 1364 | + | if selected := d.selected(); selected == nil || selected.ID != "active" { | |||
| 1365 | + | t.Fatalf("down did not select active agent: %#v", selected) | |||
| 1366 | + | } | |||
| 1367 | + | d.HandleKey(tui.Key{Kind: tui.KeyRight}) | |||
| 1368 | + | out := d.Render(tui.Theme{}, 100) | |||
| 1369 | + | joined := strings.Join(out, "\n") | |||
| 1370 | + | for _, want := range []string{"live snapshot", "inspect the dashboard", "bash", "dashboard source"} { | |||
| 1371 | + | if !strings.Contains(joined, want) { | |||
| 1372 | + | t.Errorf("expanded snapshot missing %q:\n%s", want, joined) | |||
| 1373 | + | } | |||
| 1374 | + | } | |||
| 1375 | + | if len(out) > 20 { | |||
| 1376 | + | t.Fatalf("dashboard rendered %d rows with 20-row budget:\n%s", len(out), joined) | |||
| 1377 | + | } | |||
| 1378 | + | ||||
| 1379 | + | d.HandleKey(tui.Key{Kind: tui.KeyLeft}) | |||
| 1380 | + | out = d.Render(tui.Theme{}, 100) | |||
| 1381 | + | if strings.Contains(strings.Join(out, "\n"), "live snapshot") { | |||
| 1382 | + | t.Fatalf("left did not collapse selected snapshot:\n%s", strings.Join(out, "\n")) | |||
| 1383 | + | } | |||
| 1384 | + | ||||
| 1385 | + | // Even the smallest supported dashboard retains the selected row instead | |||
| 1386 | + | // of allowing the bottom-sticky compositor to clip it above the viewport. | |||
| 1387 | + | d.SetMaxRows(4) | |||
| 1388 | + | out = d.Render(tui.Theme{}, 100) | |||
| 1389 | + | if len(out) != 4 || !strings.Contains(strings.Join(out, "\n"), "active") { | |||
| 1390 | + | t.Fatalf("short dashboard lost its selected row (%d rows):\n%s", len(out), strings.Join(out, "\n")) | |||
| 1391 | + | } | |||
| 1392 | + | } | |||
| 1393 | + | ||||
| 1322 | func TestStatusLabelCoversAll(t *testing.T) { | 1394 | func TestStatusLabelCoversAll(t *testing.T) { | ||
| 1323 | for _, s := range []subagents.Status{ | 1395 | for _, s := range []subagents.Status{ | ||
| 1324 | subagents.StatusPending, subagents.StatusRunning, | 1396 | subagents.StatusPending, subagents.StatusRunning, | ||
M packages/agent/modes/subagents_slash.go +3 -2
| @@ -77,6 +77,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||
| 77 | 77 | // editor, by typing /model on its own line to pop the picker. | |
| 78 | 78 | i.subagentsDialog.SetCompactMode(i.compactModeEnabled()) | |
| 79 | 79 | i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines) | |
| 80 | + | i.subagentsDialog.SetAllSnapshots(i.cfg.Supervisor.SnapshotAllSessions) | |
| 80 | 81 | i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript) | |
| 81 | 82 | i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | |
| 82 | 83 | if i.cfg.LoggedInProviders != nil { | |
| @@ -86,7 +87,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||
| 86 | 87 | switch sub { | |
| 87 | 88 | case "", "list", "ls", "ps": | |
| 88 | 89 | i.subagentsDialog.Open( | |
| 89 | - | i.cfg.Supervisor.SnapshotAll, | |
| 90 | + | i.cfg.Supervisor.SnapshotCurrentSession, | |
| 90 | 91 | i.cfg.Supervisor.Stop, | |
| 91 | 92 | i.cfg.Supervisor.Remove, | |
| 92 | 93 | spawnAdapter, | |
| @@ -272,7 +273,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||
| 272 | 273 | case "resume-session", "resume", "reattach", "reopen": | |
| 273 | 274 | if rest == "" { | |
| 274 | 275 | count := i.subagentsDialog.OpenForResume( | |
| 275 | - | i.cfg.Supervisor.SnapshotAll, | |
| 276 | + | i.cfg.Supervisor.SnapshotCurrentSession, | |
| 276 | 277 | i.cfg.Supervisor.Stop, | |
| 277 | 278 | i.cfg.Supervisor.Remove, | |
| 278 | 279 | spawnAdapter, | |
| @@ -77,6 +77,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||||
| 77 | // editor, by typing /model on its own line to pop the picker. | 77 | // editor, by typing /model on its own line to pop the picker. | ||
| 78 | i.subagentsDialog.SetCompactMode(i.compactModeEnabled()) | 78 | i.subagentsDialog.SetCompactMode(i.compactModeEnabled()) | ||
| 79 | i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines) | 79 | i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines) | ||
| 80 | + | i.subagentsDialog.SetAllSnapshots(i.cfg.Supervisor.SnapshotAllSessions) | |||
| 80 | i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript) | 81 | i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript) | ||
| 81 | i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | 82 | i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider) | ||
| 82 | if i.cfg.LoggedInProviders != nil { | 83 | if i.cfg.LoggedInProviders != nil { | ||
| @@ -86,7 +87,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||||
| 86 | switch sub { | 87 | switch sub { | ||
| 87 | case "", "list", "ls", "ps": | 88 | case "", "list", "ls", "ps": | ||
| 88 | i.subagentsDialog.Open( | 89 | i.subagentsDialog.Open( | ||
| 89 | - | i.cfg.Supervisor.SnapshotAll, | 90 | + | i.cfg.Supervisor.SnapshotCurrentSession, |
| 90 | i.cfg.Supervisor.Stop, | 91 | i.cfg.Supervisor.Stop, | ||
| 91 | i.cfg.Supervisor.Remove, | 92 | i.cfg.Supervisor.Remove, | ||
| 92 | spawnAdapter, | 93 | spawnAdapter, | ||
| @@ -272,7 +273,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) { | |||||
| 272 | case "resume-session", "resume", "reattach", "reopen": | 273 | case "resume-session", "resume", "reattach", "reopen": | ||
| 273 | if rest == "" { | 274 | if rest == "" { | ||
| 274 | count := i.subagentsDialog.OpenForResume( | 275 | count := i.subagentsDialog.OpenForResume( | ||
| 275 | - | i.cfg.Supervisor.SnapshotAll, | 276 | + | i.cfg.Supervisor.SnapshotCurrentSession, |
| 276 | i.cfg.Supervisor.Stop, | 277 | i.cfg.Supervisor.Stop, | ||
| 277 | i.cfg.Supervisor.Remove, | 278 | i.cfg.Supervisor.Remove, | ||
| 278 | spawnAdapter, | 279 | spawnAdapter, | ||
M packages/agent/subagents/persist.go +12 -7
| @@ -89,13 +89,10 @@ type agentMeta struct { | |||
| 89 | 89 | ResumePromptID string `json:"resume_prompt_id,omitempty"` | |
| 90 | 90 | ResumeQueue []queuedResumePrompt `json:"resume_queue,omitempty"` | |
| 91 | 91 | ||
| 92 | - | // SessionID, when non-empty, scopes the agent to a particular | |
| 93 | - | // host zut session: the dashboard only shows agents whose | |
| 94 | - | // SessionID matches the active session. Older meta.json files | |
| 95 | - | // (and agents spawned outside of any session, e.g. by tests or | |
| 96 | - | // scripted callers that didn't call SetActiveSession) have an | |
| 97 | - | // empty SessionID and are visible from every session as a | |
| 98 | - | // unscoped-agent fallback. | |
| 92 | + | // SessionID, when non-empty, scopes the agent to a particular host zut | |
| 93 | + | // session. The default dashboard view only shows the active session; | |
| 94 | + | // older unscoped metadata remains reachable through its all-sessions | |
| 95 | + | // filter. | |
| 99 | 96 | SessionID string `json:"session_id,omitempty"` | |
| 100 | 97 | } | |
| 101 | 98 | ||
| @@ -788,6 +785,14 @@ func replayEventTranscript(a *Agent, ev Event) { | |||
| 788 | 785 | } else { | |
| 789 | 786 | a.appendUserMessage(message) | |
| 790 | 787 | } | |
| 788 | + | case "tool_call", EventToolStarted: | |
| 789 | + | if name, _ := ev.Data["name"].(string); name != "" { | |
| 790 | + | a.appendTranscript("tool: " + name) | |
| 791 | + | } | |
| 792 | + | case "tool_result", EventToolFinished: | |
| 793 | + | if text := eventContentText(ev.Data); text != "" { | |
| 794 | + | a.appendTranscript("tool result: " + truncate(strings.ReplaceAll(text, "\n", " "), 240)) | |
| 795 | + | } | |
| 791 | 796 | case "stdout": | |
| 792 | 797 | if txt, _ := ev.Data["text"].(string); txt != "" { | |
| 793 | 798 | a.appendTranscript(txt) | |
| @@ -89,13 +89,10 @@ type agentMeta struct { | |||||
| 89 | ResumePromptID string `json:"resume_prompt_id,omitempty"` | 89 | ResumePromptID string `json:"resume_prompt_id,omitempty"` | ||
| 90 | ResumeQueue []queuedResumePrompt `json:"resume_queue,omitempty"` | 90 | ResumeQueue []queuedResumePrompt `json:"resume_queue,omitempty"` | ||
| 91 | 91 | ||||
| 92 | - | // SessionID, when non-empty, scopes the agent to a particular | 92 | + | // SessionID, when non-empty, scopes the agent to a particular host zut |
| 93 | - | // host zut session: the dashboard only shows agents whose | 93 | + | // session. The default dashboard view only shows the active session; |
| 94 | - | // SessionID matches the active session. Older meta.json files | 94 | + | // older unscoped metadata remains reachable through its all-sessions |
| 95 | - | // (and agents spawned outside of any session, e.g. by tests or | 95 | + | // filter. |
| 96 | - | // scripted callers that didn't call SetActiveSession) have an | |||
| 97 | - | // empty SessionID and are visible from every session as a | |||
| 98 | - | // unscoped-agent fallback. | |||
| 99 | SessionID string `json:"session_id,omitempty"` | 96 | SessionID string `json:"session_id,omitempty"` | ||
| 100 | } | 97 | } | ||
| 101 | 98 | ||||
| @@ -788,6 +785,14 @@ func replayEventTranscript(a *Agent, ev Event) { | |||||
| 788 | } else { | 785 | } else { | ||
| 789 | a.appendUserMessage(message) | 786 | a.appendUserMessage(message) | ||
| 790 | } | 787 | } | ||
| 788 | + | case "tool_call", EventToolStarted: | |||
| 789 | + | if name, _ := ev.Data["name"].(string); name != "" { | |||
| 790 | + | a.appendTranscript("tool: " + name) | |||
| 791 | + | } | |||
| 792 | + | case "tool_result", EventToolFinished: | |||
| 793 | + | if text := eventContentText(ev.Data); text != "" { | |||
| 794 | + | a.appendTranscript("tool result: " + truncate(strings.ReplaceAll(text, "\n", " "), 240)) | |||
| 795 | + | } | |||
| 791 | case "stdout": | 796 | case "stdout": | ||
| 792 | if txt, _ := ev.Data["text"].(string); txt != "" { | 797 | if txt, _ := ev.Data["text"].(string); txt != "" { | ||
| 793 | a.appendTranscript(txt) | 798 | a.appendTranscript(txt) | ||
M packages/agent/subagents/persist_test.go +37 -28
| @@ -1411,12 +1411,9 @@ func TestRemoveReloadedAgentKeepsSiblingState(t *testing.T) { | |||
| 1411 | 1411 | } | |
| 1412 | 1412 | } | |
| 1413 | 1413 | ||
| 1414 | - | // TestActiveSessionScopesSnapshotAll proves the session filter the | |
| 1415 | - | // user asked for: a single Supervisor with agents spawned under two | |
| 1416 | - | // different active sessions only surfaces the agents matching the | |
| 1417 | - | // currently-active session via SnapshotAll. Switching the active | |
| 1418 | - | // session re-narrows the view without touching agent state. | |
| 1419 | - | func TestActiveSessionScopesSnapshotAll(t *testing.T) { | |
| 1414 | + | // TestActiveSessionScopesSnapshotCurrentSession proves the dashboard's | |
| 1415 | + | // default session filter. SnapshotAll remains the explicit cross-session view. | |
| 1416 | + | func TestActiveSessionScopesSnapshotCurrentSession(t *testing.T) { | |
| 1420 | 1417 | root := t.TempDir() | |
| 1421 | 1418 | f := New(Config{ | |
| 1422 | 1419 | Root: root, RepoRoot: root, | |
| @@ -1447,23 +1444,29 @@ func TestActiveSessionScopesSnapshotAll(t *testing.T) { | |||
| 1447 | 1444 | } | |
| 1448 | 1445 | ||
| 1449 | 1446 | // Active session B → only aB visible. | |
| 1450 | - | only := snapshotIDs(f.SnapshotAll()) | |
| 1447 | + | only := snapshotIDs(f.SnapshotCurrentSession()) | |
| 1451 | 1448 | if len(only) != 1 || only[0] != aB.ID { | |
| 1452 | 1449 | t.Errorf("scoped to sess-B, snapshot ids = %v; want [%s]", only, aB.ID) | |
| 1453 | 1450 | } | |
| 1454 | 1451 | ||
| 1455 | 1452 | // Switch back to A → only aA visible. | |
| 1456 | 1453 | f.SetActiveSession("sess-A") | |
| 1457 | - | only = snapshotIDs(f.SnapshotAll()) | |
| 1454 | + | only = snapshotIDs(f.SnapshotCurrentSession()) | |
| 1458 | 1455 | if len(only) != 1 || only[0] != aA.ID { | |
| 1459 | 1456 | t.Errorf("scoped to sess-A, snapshot ids = %v; want [%s]", only, aA.ID) | |
| 1460 | 1457 | } | |
| 1461 | 1458 | ||
| 1462 | - | // Clear the scope → both visible. | |
| 1463 | - | f.SetActiveSession("") | |
| 1464 | - | all := snapshotIDs(f.SnapshotAll()) | |
| 1459 | + | // The All filter is independent of the active session. | |
| 1460 | + | all := snapshotIDs(f.SnapshotAllSessions()) | |
| 1465 | 1461 | if len(all) != 2 { | |
| 1466 | - | t.Errorf("unscoped snapshot ids = %v; want both agents", all) | |
| 1462 | + | t.Errorf("all-session snapshot ids = %v; want both agents", all) | |
| 1463 | + | } | |
| 1464 | + | ||
| 1465 | + | // Clear the scope → both visible from the default view too. | |
| 1466 | + | f.SetActiveSession("") | |
| 1467 | + | only = snapshotIDs(f.SnapshotCurrentSession()) | |
| 1468 | + | if len(only) != 2 { | |
| 1469 | + | t.Errorf("unscoped current-session ids = %v; want both agents", only) | |
| 1467 | 1470 | } | |
| 1468 | 1471 | ||
| 1469 | 1472 | // Cleanup. | |
| @@ -1505,26 +1508,26 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) { | |||
| 1505 | 1508 | t.Fatalf("reload loaded=%d errs=%v; want 1 / no errs", loaded, errs) | |
| 1506 | 1509 | } | |
| 1507 | 1510 | g.SetActiveSession("sess-keep") | |
| 1508 | - | got := snapshotIDs(g.SnapshotAll()) | |
| 1511 | + | got := snapshotIDs(g.SnapshotCurrentSession()) | |
| 1509 | 1512 | if len(got) != 1 || got[0] != a.ID { | |
| 1510 | 1513 | t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID) | |
| 1511 | 1514 | } | |
| 1512 | 1515 | ||
| 1513 | - | // A terminal persisted agent remains visible from another host session | |
| 1514 | - | // so a normal restart does not hide durable recovery state. | |
| 1516 | + | // A terminal persisted agent is hidden by the default session view, but | |
| 1517 | + | // remains available from the explicit all-sessions filter. | |
| 1515 | 1518 | g.SetActiveSession("sess-other") | |
| 1516 | - | if got := snapshotIDs(g.SnapshotAll()); len(got) != 1 || got[0] != a.ID { | |
| 1517 | - | t.Errorf("scoped to other session, ids = %v; want [%s]", got, a.ID) | |
| 1519 | + | if got := snapshotIDs(g.SnapshotCurrentSession()); len(got) != 0 { | |
| 1520 | + | t.Errorf("current session scoped to other session, ids = %v; want none", got) | |
| 1521 | + | } | |
| 1522 | + | if got := snapshotIDs(g.SnapshotAllSessions()); len(got) != 1 || got[0] != a.ID { | |
| 1523 | + | t.Errorf("all sessions ids = %v; want [%s]", got, a.ID) | |
| 1518 | 1524 | } | |
| 1519 | 1525 | } | |
| 1520 | 1526 | ||
| 1521 | - | // TestEmptySessionIDIsVisibleFromAnyScope pins the unscoped-agent | |
| 1522 | - | // rule: agents whose meta.json predates the SessionID field (or who | |
| 1523 | - | // were spawned without an active session, e.g. via a test rig or | |
| 1524 | - | // scripted caller) carry SessionID == "" and remain visible from | |
| 1525 | - | // every scope. Otherwise the schema bump would orphan every | |
| 1526 | - | // pre-existing agent the moment a user upgraded zut. | |
| 1527 | - | func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) { | |
| 1527 | + | // TestEmptySessionIDIsHiddenFromScopedViews keeps historical unscoped | |
| 1528 | + | // agents out of a session-specific dashboard while retaining them in the | |
| 1529 | + | // explicit all-sessions view. | |
| 1530 | + | func TestEmptySessionIDIsHiddenFromScopedViews(t *testing.T) { | |
| 1528 | 1531 | root := t.TempDir() | |
| 1529 | 1532 | f := New(Config{ | |
| 1530 | 1533 | Root: root, RepoRoot: root, | |
| @@ -1541,11 +1544,17 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) { | |||
| 1541 | 1544 | t.Fatalf("unscoped spawn produced SessionID %q; want empty", a.SessionID) | |
| 1542 | 1545 | } | |
| 1543 | 1546 | ||
| 1544 | - | for _, scope := range []string{"", "any-session", "some-other"} { | |
| 1547 | + | f.SetActiveSession("") | |
| 1548 | + | if got := snapshotIDs(f.SnapshotCurrentSession()); len(got) != 1 || got[0] != a.ID { | |
| 1549 | + | t.Errorf("unscoped view ids = %v; want [%s]", got, a.ID) | |
| 1550 | + | } | |
| 1551 | + | for _, scope := range []string{"any-session", "some-other"} { | |
| 1545 | 1552 | f.SetActiveSession(scope) | |
| 1546 | - | got := snapshotIDs(f.SnapshotAll()) | |
| 1547 | - | if len(got) != 1 || got[0] != a.ID { | |
| 1548 | - | t.Errorf("scope=%q: ids = %v; want unscoped agent visible", scope, got) | |
| 1553 | + | if got := snapshotIDs(f.SnapshotCurrentSession()); len(got) != 0 { | |
| 1554 | + | t.Errorf("scope=%q: ids = %v; want none", scope, got) | |
| 1555 | + | } | |
| 1556 | + | if got := snapshotIDs(f.SnapshotAllSessions()); len(got) != 1 || got[0] != a.ID { | |
| 1557 | + | t.Errorf("all sessions for scope=%q: ids = %v; want [%s]", scope, got, a.ID) | |
| 1549 | 1558 | } | |
| 1550 | 1559 | } | |
| 1551 | 1560 | ||
| @@ -1411,12 +1411,9 @@ func TestRemoveReloadedAgentKeepsSiblingState(t *testing.T) { | |||||
| 1411 | } | 1411 | } | ||
| 1412 | } | 1412 | } | ||
| 1413 | 1413 | ||||
| 1414 | - | // TestActiveSessionScopesSnapshotAll proves the session filter the | 1414 | + | // TestActiveSessionScopesSnapshotCurrentSession proves the dashboard's |
| 1415 | - | // user asked for: a single Supervisor with agents spawned under two | 1415 | + | // default session filter. SnapshotAll remains the explicit cross-session view. |
| 1416 | - | // different active sessions only surfaces the agents matching the | 1416 | + | func TestActiveSessionScopesSnapshotCurrentSession(t *testing.T) { |
| 1417 | - | // currently-active session via SnapshotAll. Switching the active | |||
| 1418 | - | // session re-narrows the view without touching agent state. | |||
| 1419 | - | func TestActiveSessionScopesSnapshotAll(t *testing.T) { | |||
| 1420 | root := t.TempDir() | 1417 | root := t.TempDir() | ||
| 1421 | f := New(Config{ | 1418 | f := New(Config{ | ||
| 1422 | Root: root, RepoRoot: root, | 1419 | Root: root, RepoRoot: root, | ||
| @@ -1447,23 +1444,29 @@ func TestActiveSessionScopesSnapshotAll(t *testing.T) { | |||||
| 1447 | } | 1444 | } | ||
| 1448 | 1445 | ||||
| 1449 | // Active session B → only aB visible. | 1446 | // Active session B → only aB visible. | ||
| 1450 | - | only := snapshotIDs(f.SnapshotAll()) | 1447 | + | only := snapshotIDs(f.SnapshotCurrentSession()) |
| 1451 | if len(only) != 1 || only[0] != aB.ID { | 1448 | if len(only) != 1 || only[0] != aB.ID { | ||
| 1452 | t.Errorf("scoped to sess-B, snapshot ids = %v; want [%s]", only, aB.ID) | 1449 | t.Errorf("scoped to sess-B, snapshot ids = %v; want [%s]", only, aB.ID) | ||
| 1453 | } | 1450 | } | ||
| 1454 | 1451 | ||||
| 1455 | // Switch back to A → only aA visible. | 1452 | // Switch back to A → only aA visible. | ||
| 1456 | f.SetActiveSession("sess-A") | 1453 | f.SetActiveSession("sess-A") | ||
| 1457 | - | only = snapshotIDs(f.SnapshotAll()) | 1454 | + | only = snapshotIDs(f.SnapshotCurrentSession()) |
| 1458 | if len(only) != 1 || only[0] != aA.ID { | 1455 | if len(only) != 1 || only[0] != aA.ID { | ||
| 1459 | t.Errorf("scoped to sess-A, snapshot ids = %v; want [%s]", only, aA.ID) | 1456 | t.Errorf("scoped to sess-A, snapshot ids = %v; want [%s]", only, aA.ID) | ||
| 1460 | } | 1457 | } | ||
| 1461 | 1458 | ||||
| 1462 | - | // Clear the scope → both visible. | 1459 | + | // The All filter is independent of the active session. |
| 1463 | - | f.SetActiveSession("") | 1460 | + | all := snapshotIDs(f.SnapshotAllSessions()) |
| 1464 | - | all := snapshotIDs(f.SnapshotAll()) | |||
| 1465 | if len(all) != 2 { | 1461 | if len(all) != 2 { | ||
| 1466 | - | t.Errorf("unscoped snapshot ids = %v; want both agents", all) | 1462 | + | t.Errorf("all-session snapshot ids = %v; want both agents", all) |
| 1463 | + | } | |||
| 1464 | + | ||||
| 1465 | + | // Clear the scope → both visible from the default view too. | |||
| 1466 | + | f.SetActiveSession("") | |||
| 1467 | + | only = snapshotIDs(f.SnapshotCurrentSession()) | |||
| 1468 | + | if len(only) != 2 { | |||
| 1469 | + | t.Errorf("unscoped current-session ids = %v; want both agents", only) | |||
| 1467 | } | 1470 | } | ||
| 1468 | 1471 | ||||
| 1469 | // Cleanup. | 1472 | // Cleanup. | ||
| @@ -1505,26 +1508,26 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) { | |||||
| 1505 | t.Fatalf("reload loaded=%d errs=%v; want 1 / no errs", loaded, errs) | 1508 | t.Fatalf("reload loaded=%d errs=%v; want 1 / no errs", loaded, errs) | ||
| 1506 | } | 1509 | } | ||
| 1507 | g.SetActiveSession("sess-keep") | 1510 | g.SetActiveSession("sess-keep") | ||
| 1508 | - | got := snapshotIDs(g.SnapshotAll()) | 1511 | + | got := snapshotIDs(g.SnapshotCurrentSession()) |
| 1509 | if len(got) != 1 || got[0] != a.ID { | 1512 | if len(got) != 1 || got[0] != a.ID { | ||
| 1510 | t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID) | 1513 | t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID) | ||
| 1511 | } | 1514 | } | ||
| 1512 | 1515 | ||||
| 1513 | - | // A terminal persisted agent remains visible from another host session | 1516 | + | // A terminal persisted agent is hidden by the default session view, but |
| 1514 | - | // so a normal restart does not hide durable recovery state. | 1517 | + | // remains available from the explicit all-sessions filter. |
| 1515 | g.SetActiveSession("sess-other") | 1518 | g.SetActiveSession("sess-other") | ||
| 1516 | - | if got := snapshotIDs(g.SnapshotAll()); len(got) != 1 || got[0] != a.ID { | 1519 | + | if got := snapshotIDs(g.SnapshotCurrentSession()); len(got) != 0 { |
| 1517 | - | t.Errorf("scoped to other session, ids = %v; want [%s]", got, a.ID) | 1520 | + | t.Errorf("current session scoped to other session, ids = %v; want none", got) |
| 1521 | + | } | |||
| 1522 | + | if got := snapshotIDs(g.SnapshotAllSessions()); len(got) != 1 || got[0] != a.ID { | |||
| 1523 | + | t.Errorf("all sessions ids = %v; want [%s]", got, a.ID) | |||
| 1518 | } | 1524 | } | ||
| 1519 | } | 1525 | } | ||
| 1520 | 1526 | ||||
| 1521 | - | // TestEmptySessionIDIsVisibleFromAnyScope pins the unscoped-agent | 1527 | + | // TestEmptySessionIDIsHiddenFromScopedViews keeps historical unscoped |
| 1522 | - | // rule: agents whose meta.json predates the SessionID field (or who | 1528 | + | // agents out of a session-specific dashboard while retaining them in the |
| 1523 | - | // were spawned without an active session, e.g. via a test rig or | 1529 | + | // explicit all-sessions view. |
| 1524 | - | // scripted caller) carry SessionID == "" and remain visible from | 1530 | + | func TestEmptySessionIDIsHiddenFromScopedViews(t *testing.T) { |
| 1525 | - | // every scope. Otherwise the schema bump would orphan every | |||
| 1526 | - | // pre-existing agent the moment a user upgraded zut. | |||
| 1527 | - | func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) { | |||
| 1528 | root := t.TempDir() | 1531 | root := t.TempDir() | ||
| 1529 | f := New(Config{ | 1532 | f := New(Config{ | ||
| 1530 | Root: root, RepoRoot: root, | 1533 | Root: root, RepoRoot: root, | ||
| @@ -1541,11 +1544,17 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) { | |||||
| 1541 | t.Fatalf("unscoped spawn produced SessionID %q; want empty", a.SessionID) | 1544 | t.Fatalf("unscoped spawn produced SessionID %q; want empty", a.SessionID) | ||
| 1542 | } | 1545 | } | ||
| 1543 | 1546 | ||||
| 1544 | - | for _, scope := range []string{"", "any-session", "some-other"} { | 1547 | + | f.SetActiveSession("") |
| 1548 | + | if got := snapshotIDs(f.SnapshotCurrentSession()); len(got) != 1 || got[0] != a.ID { | |||
| 1549 | + | t.Errorf("unscoped view ids = %v; want [%s]", got, a.ID) | |||
| 1550 | + | } | |||
| 1551 | + | for _, scope := range []string{"any-session", "some-other"} { | |||
| 1545 | f.SetActiveSession(scope) | 1552 | f.SetActiveSession(scope) | ||
| 1546 | - | got := snapshotIDs(f.SnapshotAll()) | 1553 | + | if got := snapshotIDs(f.SnapshotCurrentSession()); len(got) != 0 { |
| 1547 | - | if len(got) != 1 || got[0] != a.ID { | 1554 | + | t.Errorf("scope=%q: ids = %v; want none", scope, got) |
| 1548 | - | t.Errorf("scope=%q: ids = %v; want unscoped agent visible", scope, got) | 1555 | + | } |
| 1556 | + | if got := snapshotIDs(f.SnapshotAllSessions()); len(got) != 1 || got[0] != a.ID { | |||
| 1557 | + | t.Errorf("all sessions for scope=%q: ids = %v; want [%s]", scope, got, a.ID) | |||
| 1549 | } | 1558 | } | ||
| 1550 | } | 1559 | } | ||
| 1551 | 1560 | ||||
M packages/agent/subagents/runner.go +30 -0
| @@ -1006,8 +1006,12 @@ func applyEventToSink(ev Event, sink Sink) { | |||
| 1006 | 1006 | case "tool_call", EventToolStarted: | |
| 1007 | 1007 | if name, _ := ev.Data["name"].(string); name != "" { | |
| 1008 | 1008 | sink.Activity("tool: " + truncate(name, 60)) | |
| 1009 | + | sink.Transcript("tool: " + name) | |
| 1009 | 1010 | } | |
| 1010 | 1011 | case "tool_result", EventToolFinished: | |
| 1012 | + | if text := eventContentText(ev.Data); text != "" { | |
| 1013 | + | sink.Transcript("tool result: " + truncate(strings.ReplaceAll(text, "\n", " "), 240)) | |
| 1014 | + | } | |
| 1011 | 1015 | sink.Activity("idle") | |
| 1012 | 1016 | case "turn_end", EventTurnResult, EventAgentIdle: | |
| 1013 | 1017 | sink.Activity("idle") | |
| @@ -1024,6 +1028,32 @@ func applyEventToSink(ev Event, sink Sink) { | |||
| 1024 | 1028 | } | |
| 1025 | 1029 | } | |
| 1026 | 1030 | ||
| 1031 | + | // eventContentText extracts the human-readable text carried by a tool-result | |
| 1032 | + | // event. The event stream is permissive across provider adapters, so accept | |
| 1033 | + | // both the canonical block array and simple string payloads. | |
| 1034 | + | func eventContentText(data map[string]any) string { | |
| 1035 | + | for _, key := range []string{"content", "output", "text"} { | |
| 1036 | + | switch value := data[key].(type) { | |
| 1037 | + | case string: | |
| 1038 | + | if value != "" { | |
| 1039 | + | return value | |
| 1040 | + | } | |
| 1041 | + | case []any: | |
| 1042 | + | var text []string | |
| 1043 | + | for _, item := range value { | |
| 1044 | + | block, _ := item.(map[string]any) | |
| 1045 | + | if value, _ := block["text"].(string); value != "" { | |
| 1046 | + | text = append(text, value) | |
| 1047 | + | } | |
| 1048 | + | } | |
| 1049 | + | if len(text) > 0 { | |
| 1050 | + | return strings.Join(text, "\n") | |
| 1051 | + | } | |
| 1052 | + | } | |
| 1053 | + | } | |
| 1054 | + | return "" | |
| 1055 | + | } | |
| 1056 | + | ||
| 1027 | 1057 | // RunnerFunc adapts a plain function into a Runner. Useful for tests | |
| 1028 | 1058 | // and for callers who don't need their own type. | |
| 1029 | 1059 | type RunnerFunc func(ctx context.Context, sink Sink) error | |
| @@ -1006,8 +1006,12 @@ func applyEventToSink(ev Event, sink Sink) { | |||||
| 1006 | case "tool_call", EventToolStarted: | 1006 | case "tool_call", EventToolStarted: | ||
| 1007 | if name, _ := ev.Data["name"].(string); name != "" { | 1007 | if name, _ := ev.Data["name"].(string); name != "" { | ||
| 1008 | sink.Activity("tool: " + truncate(name, 60)) | 1008 | sink.Activity("tool: " + truncate(name, 60)) | ||
| 1009 | + | sink.Transcript("tool: " + name) | |||
| 1009 | } | 1010 | } | ||
| 1010 | case "tool_result", EventToolFinished: | 1011 | case "tool_result", EventToolFinished: | ||
| 1012 | + | if text := eventContentText(ev.Data); text != "" { | |||
| 1013 | + | sink.Transcript("tool result: " + truncate(strings.ReplaceAll(text, "\n", " "), 240)) | |||
| 1014 | + | } | |||
| 1011 | sink.Activity("idle") | 1015 | sink.Activity("idle") | ||
| 1012 | case "turn_end", EventTurnResult, EventAgentIdle: | 1016 | case "turn_end", EventTurnResult, EventAgentIdle: | ||
| 1013 | sink.Activity("idle") | 1017 | sink.Activity("idle") | ||
| @@ -1024,6 +1028,32 @@ func applyEventToSink(ev Event, sink Sink) { | |||||
| 1024 | } | 1028 | } | ||
| 1025 | } | 1029 | } | ||
| 1026 | 1030 | ||||
| 1031 | + | // eventContentText extracts the human-readable text carried by a tool-result | |||
| 1032 | + | // event. The event stream is permissive across provider adapters, so accept | |||
| 1033 | + | // both the canonical block array and simple string payloads. | |||
| 1034 | + | func eventContentText(data map[string]any) string { | |||
| 1035 | + | for _, key := range []string{"content", "output", "text"} { | |||
| 1036 | + | switch value := data[key].(type) { | |||
| 1037 | + | case string: | |||
| 1038 | + | if value != "" { | |||
| 1039 | + | return value | |||
| 1040 | + | } | |||
| 1041 | + | case []any: | |||
| 1042 | + | var text []string | |||
| 1043 | + | for _, item := range value { | |||
| 1044 | + | block, _ := item.(map[string]any) | |||
| 1045 | + | if value, _ := block["text"].(string); value != "" { | |||
| 1046 | + | text = append(text, value) | |||
| 1047 | + | } | |||
| 1048 | + | } | |||
| 1049 | + | if len(text) > 0 { | |||
| 1050 | + | return strings.Join(text, "\n") | |||
| 1051 | + | } | |||
| 1052 | + | } | |||
| 1053 | + | } | |||
| 1054 | + | return "" | |||
| 1055 | + | } | |||
| 1056 | + | ||||
| 1027 | // RunnerFunc adapts a plain function into a Runner. Useful for tests | 1057 | // RunnerFunc adapts a plain function into a Runner. Useful for tests | ||
| 1028 | // and for callers who don't need their own type. | 1058 | // and for callers who don't need their own type. | ||
| 1029 | type RunnerFunc func(ctx context.Context, sink Sink) error | 1059 | type RunnerFunc func(ctx context.Context, sink Sink) error | ||
M packages/agent/subagents/runner_test.go +27 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "os/exec" | |
| 9 | 9 | "path/filepath" | |
| 10 | 10 | "runtime" | |
| 11 | + | "slices" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "testing" | |
| 13 | 14 | "time" | |
| @@ -800,6 +801,32 @@ func TestEventCounterRejectsNegativeFractionalAndOverflowValues(t *testing.T) { | |||
| 800 | 801 | } | |
| 801 | 802 | } | |
| 802 | 803 | ||
| 804 | + | func TestApplyEventToSinkProjectsToolActivityIntoTranscript(t *testing.T) { | |
| 805 | + | events := []Event{ | |
| 806 | + | {Type: EventToolStarted, Data: map[string]any{"name": "bash"}}, | |
| 807 | + | {Type: EventToolFinished, Data: map[string]any{ | |
| 808 | + | "content": []any{map[string]any{"text": "command output"}}, | |
| 809 | + | }}, | |
| 810 | + | } | |
| 811 | + | want := []string{"tool: bash", "tool result: command output"} | |
| 812 | + | ||
| 813 | + | live := &Agent{} | |
| 814 | + | for _, event := range events { | |
| 815 | + | applyEventToSink(event, agentSink{a: live}) | |
| 816 | + | } | |
| 817 | + | if got := live.Transcript(); !slices.Equal(got, want) { | |
| 818 | + | t.Fatalf("live tool transcript = %#v; want %#v", got, want) | |
| 819 | + | } | |
| 820 | + | ||
| 821 | + | replayed := &Agent{} | |
| 822 | + | for _, event := range events { | |
| 823 | + | replayEventTranscript(replayed, event) | |
| 824 | + | } | |
| 825 | + | if got := replayed.Transcript(); !slices.Equal(got, want) { | |
| 826 | + | t.Fatalf("replayed tool transcript = %#v; want %#v", got, want) | |
| 827 | + | } | |
| 828 | + | } | |
| 829 | + | ||
| 803 | 830 | func indexOf(xs []string, want string) int { | |
| 804 | 831 | for i, x := range xs { | |
| 805 | 832 | if x == want { | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "os/exec" | 8 | "os/exec" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | "runtime" | 10 | "runtime" | ||
| 11 | + | "slices" | |||
| 11 | "strings" | 12 | "strings" | ||
| 12 | "testing" | 13 | "testing" | ||
| 13 | "time" | 14 | "time" | ||
| @@ -800,6 +801,32 @@ func TestEventCounterRejectsNegativeFractionalAndOverflowValues(t *testing.T) { | |||||
| 800 | } | 801 | } | ||
| 801 | } | 802 | } | ||
| 802 | 803 | ||||
| 804 | + | func TestApplyEventToSinkProjectsToolActivityIntoTranscript(t *testing.T) { | |||
| 805 | + | events := []Event{ | |||
| 806 | + | {Type: EventToolStarted, Data: map[string]any{"name": "bash"}}, | |||
| 807 | + | {Type: EventToolFinished, Data: map[string]any{ | |||
| 808 | + | "content": []any{map[string]any{"text": "command output"}}, | |||
| 809 | + | }}, | |||
| 810 | + | } | |||
| 811 | + | want := []string{"tool: bash", "tool result: command output"} | |||
| 812 | + | ||||
| 813 | + | live := &Agent{} | |||
| 814 | + | for _, event := range events { | |||
| 815 | + | applyEventToSink(event, agentSink{a: live}) | |||
| 816 | + | } | |||
| 817 | + | if got := live.Transcript(); !slices.Equal(got, want) { | |||
| 818 | + | t.Fatalf("live tool transcript = %#v; want %#v", got, want) | |||
| 819 | + | } | |||
| 820 | + | ||||
| 821 | + | replayed := &Agent{} | |||
| 822 | + | for _, event := range events { | |||
| 823 | + | replayEventTranscript(replayed, event) | |||
| 824 | + | } | |||
| 825 | + | if got := replayed.Transcript(); !slices.Equal(got, want) { | |||
| 826 | + | t.Fatalf("replayed tool transcript = %#v; want %#v", got, want) | |||
| 827 | + | } | |||
| 828 | + | } | |||
| 829 | + | ||||
| 803 | func indexOf(xs []string, want string) int { | 830 | func indexOf(xs []string, want string) int { | ||
| 804 | for i, x := range xs { | 831 | for i, x := range xs { | ||
| 805 | if x == want { | 832 | if x == want { | ||
M packages/agent/subagents/supervisor.go +35 -21
| @@ -1202,34 +1202,47 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||
| 1202 | 1202 | } | |
| 1203 | 1203 | } | |
| 1204 | 1204 | ||
| 1205 | - | // SnapshotAll returns snapshots of every agent in creation order, | |
| 1206 | - | // scoped to the active session when one is set. | |
| 1207 | - | // | |
| 1208 | - | // Scoping rules: | |
| 1209 | - | // - activeSession == "": no filter, every agent is returned | |
| 1210 | - | // (historical behaviour; used by tests and scripted callers). | |
| 1211 | - | // - activeSession != "": include live agents whose SessionID matches | |
| 1212 | - | // activeSession, plus unscoped agents. Detached and terminal | |
| 1213 | - | // agents from another host session remain visible so restart/recovery | |
| 1214 | - | // does not hide durable work. | |
| 1205 | + | // SnapshotAll returns the supervisor's established status view: live agents | |
| 1206 | + | // are scoped to the active session, while durable terminal entries remain | |
| 1207 | + | // discoverable for recovery callers. | |
| 1215 | 1208 | func (f *Supervisor) SnapshotAll() []AgentSnapshot { | |
| 1216 | - | agents := f.List() | |
| 1217 | 1209 | f.mu.Lock() | |
| 1218 | 1210 | active := f.activeSession | |
| 1219 | 1211 | f.mu.Unlock() | |
| 1212 | + | return f.snapshotMatching(func(a *Agent) bool { | |
| 1213 | + | if active == "" || a.SessionID == "" || a.SessionID == active { | |
| 1214 | + | return true | |
| 1215 | + | } | |
| 1216 | + | status := a.Status() | |
| 1217 | + | return status != StatusRunning && status != StatusPending | |
| 1218 | + | }) | |
| 1219 | + | } | |
| 1220 | + | ||
| 1221 | + | // SnapshotAllSessions returns every agent, regardless of host session. It is | |
| 1222 | + | // the explicit cross-session view used by the dashboard's All filter. | |
| 1223 | + | func (f *Supervisor) SnapshotAllSessions() []AgentSnapshot { | |
| 1224 | + | return f.snapshotMatching(func(*Agent) bool { return true }) | |
| 1225 | + | } | |
| 1220 | 1226 | ||
| 1227 | + | // SnapshotCurrentSession returns only agents owned by the active host | |
| 1228 | + | // session. When no active session is set, it includes every agent for | |
| 1229 | + | // command-line and test callers that do not establish a session boundary. | |
| 1230 | + | func (f *Supervisor) SnapshotCurrentSession() []AgentSnapshot { | |
| 1231 | + | f.mu.Lock() | |
| 1232 | + | active := f.activeSession | |
| 1233 | + | f.mu.Unlock() | |
| 1234 | + | return f.snapshotMatching(func(a *Agent) bool { | |
| 1235 | + | return active == "" || a.SessionID == active | |
| 1236 | + | }) | |
| 1237 | + | } | |
| 1238 | + | ||
| 1239 | + | func (f *Supervisor) snapshotMatching(include func(*Agent) bool) []AgentSnapshot { | |
| 1240 | + | agents := f.List() | |
| 1221 | 1241 | out := make([]AgentSnapshot, 0, len(agents)) | |
| 1222 | 1242 | for _, a := range agents { | |
| 1223 | - | if active != "" && a.SessionID != "" && a.SessionID != active { | |
| 1224 | - | // Keep detached/terminal entries visible after a normal host | |
| 1225 | - | // restart so durable recovery remains discoverable. Hide only | |
| 1226 | - | // live work belonging to another in-process host session. | |
| 1227 | - | status := a.Status() | |
| 1228 | - | if status == StatusRunning || status == StatusPending { | |
| 1229 | - | continue | |
| 1230 | - | } | |
| 1243 | + | if include(a) { | |
| 1244 | + | out = append(out, a.Snapshot()) | |
| 1231 | 1245 | } | |
| 1232 | - | out = append(out, a.Snapshot()) | |
| 1233 | 1246 | } | |
| 1234 | 1247 | // Sort by start time for a stable, deterministic listing. | |
| 1235 | 1248 | sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) }) | |
| @@ -1241,7 +1254,8 @@ type agentSink struct{ a *Agent } | |||
| 1241 | 1254 | ||
| 1242 | 1255 | func (s agentSink) Activity(msg string) { s.a.setActivity(msg) } | |
| 1243 | 1256 | func (s agentSink) Transcript(chunk string) { | |
| 1244 | - | if strings.HasPrefix(chunk, "stderr: ") || strings.HasPrefix(chunk, "error: ") { | |
| 1257 | + | if strings.HasPrefix(chunk, "stderr: ") || strings.HasPrefix(chunk, "error: ") || | |
| 1258 | + | strings.HasPrefix(chunk, "tool: ") || strings.HasPrefix(chunk, "tool result: ") { | |
| 1245 | 1259 | s.a.appendTranscript(chunk) | |
| 1246 | 1260 | return | |
| 1247 | 1261 | } | |
| @@ -1202,34 +1202,47 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||||
| 1202 | } | 1202 | } | ||
| 1203 | } | 1203 | } | ||
| 1204 | 1204 | ||||
| 1205 | - | // SnapshotAll returns snapshots of every agent in creation order, | 1205 | + | // SnapshotAll returns the supervisor's established status view: live agents |
| 1206 | - | // scoped to the active session when one is set. | 1206 | + | // are scoped to the active session, while durable terminal entries remain |
| 1207 | - | // | 1207 | + | // discoverable for recovery callers. |
| 1208 | - | // Scoping rules: | |||
| 1209 | - | // - activeSession == "": no filter, every agent is returned | |||
| 1210 | - | // (historical behaviour; used by tests and scripted callers). | |||
| 1211 | - | // - activeSession != "": include live agents whose SessionID matches | |||
| 1212 | - | // activeSession, plus unscoped agents. Detached and terminal | |||
| 1213 | - | // agents from another host session remain visible so restart/recovery | |||
| 1214 | - | // does not hide durable work. | |||
| 1215 | func (f *Supervisor) SnapshotAll() []AgentSnapshot { | 1208 | func (f *Supervisor) SnapshotAll() []AgentSnapshot { | ||
| 1216 | - | agents := f.List() | |||
| 1217 | f.mu.Lock() | 1209 | f.mu.Lock() | ||
| 1218 | active := f.activeSession | 1210 | active := f.activeSession | ||
| 1219 | f.mu.Unlock() | 1211 | f.mu.Unlock() | ||
| 1212 | + | return f.snapshotMatching(func(a *Agent) bool { | |||
| 1213 | + | if active == "" || a.SessionID == "" || a.SessionID == active { | |||
| 1214 | + | return true | |||
| 1215 | + | } | |||
| 1216 | + | status := a.Status() | |||
| 1217 | + | return status != StatusRunning && status != StatusPending | |||
| 1218 | + | }) | |||
| 1219 | + | } | |||
| 1220 | + | ||||
| 1221 | + | // SnapshotAllSessions returns every agent, regardless of host session. It is | |||
| 1222 | + | // the explicit cross-session view used by the dashboard's All filter. | |||
| 1223 | + | func (f *Supervisor) SnapshotAllSessions() []AgentSnapshot { | |||
| 1224 | + | return f.snapshotMatching(func(*Agent) bool { return true }) | |||
| 1225 | + | } | |||
| 1220 | 1226 | ||||
| 1227 | + | // SnapshotCurrentSession returns only agents owned by the active host | |||
| 1228 | + | // session. When no active session is set, it includes every agent for | |||
| 1229 | + | // command-line and test callers that do not establish a session boundary. | |||
| 1230 | + | func (f *Supervisor) SnapshotCurrentSession() []AgentSnapshot { | |||
| 1231 | + | f.mu.Lock() | |||
| 1232 | + | active := f.activeSession | |||
| 1233 | + | f.mu.Unlock() | |||
| 1234 | + | return f.snapshotMatching(func(a *Agent) bool { | |||
| 1235 | + | return active == "" || a.SessionID == active | |||
| 1236 | + | }) | |||
| 1237 | + | } | |||
| 1238 | + | ||||
| 1239 | + | func (f *Supervisor) snapshotMatching(include func(*Agent) bool) []AgentSnapshot { | |||
| 1240 | + | agents := f.List() | |||
| 1221 | out := make([]AgentSnapshot, 0, len(agents)) | 1241 | out := make([]AgentSnapshot, 0, len(agents)) | ||
| 1222 | for _, a := range agents { | 1242 | for _, a := range agents { | ||
| 1223 | - | if active != "" && a.SessionID != "" && a.SessionID != active { | 1243 | + | if include(a) { |
| 1224 | - | // Keep detached/terminal entries visible after a normal host | 1244 | + | out = append(out, a.Snapshot()) |
| 1225 | - | // restart so durable recovery remains discoverable. Hide only | |||
| 1226 | - | // live work belonging to another in-process host session. | |||
| 1227 | - | status := a.Status() | |||
| 1228 | - | if status == StatusRunning || status == StatusPending { | |||
| 1229 | - | continue | |||
| 1230 | - | } | |||
| 1231 | } | 1245 | } | ||
| 1232 | - | out = append(out, a.Snapshot()) | |||
| 1233 | } | 1246 | } | ||
| 1234 | // Sort by start time for a stable, deterministic listing. | 1247 | // Sort by start time for a stable, deterministic listing. | ||
| 1235 | sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) }) | 1248 | sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) }) | ||
| @@ -1241,7 +1254,8 @@ type agentSink struct{ a *Agent } | |||||
| 1241 | 1254 | ||||
| 1242 | func (s agentSink) Activity(msg string) { s.a.setActivity(msg) } | 1255 | func (s agentSink) Activity(msg string) { s.a.setActivity(msg) } | ||
| 1243 | func (s agentSink) Transcript(chunk string) { | 1256 | func (s agentSink) Transcript(chunk string) { | ||
| 1244 | - | if strings.HasPrefix(chunk, "stderr: ") || strings.HasPrefix(chunk, "error: ") { | 1257 | + | if strings.HasPrefix(chunk, "stderr: ") || strings.HasPrefix(chunk, "error: ") || |
| 1258 | + | strings.HasPrefix(chunk, "tool: ") || strings.HasPrefix(chunk, "tool result: ") { | |||
| 1245 | s.a.appendTranscript(chunk) | 1259 | s.a.appendTranscript(chunk) | ||
| 1246 | return | 1260 | return | ||
| 1247 | } | 1261 | } | ||