Files
zero/internal/tui/collapse_tools_test.go
Vasanth T 258f1a31c9 fix(swarm): make swarm_collect wait for members instead of polling (#310)
* fix(swarm): make swarm_collect wait for members instead of polling

Swarm spawn was fire-and-forget and the only way to learn a member had
finished was to call swarm_status/swarm_collect, which returned an instant
snapshot. So an orchestrator had to poll in a loop, flooding the chat with
repeated status cards and never getting a clean "done".

swarm_collect now BLOCKS until every member of the team reaches a terminal
state, then returns their results in one call:

- Coordinator gains a change-broadcast channel and WaitSettled(ctx, team),
  which blocks until the scope has no pending/running task (or ctx is done).
  Every state transition (register/setstatus/finish/reassign) wakes waiters.
- Swarm.CollectWait wraps it; the collect tool calls it bounded by the run
  context (user cancel) and a timeout (default 600s, cap 1800s, overridable
  via timeout_seconds), so a stuck member returns partial state instead of
  hanging forever.
- Tool descriptions now steer the model: spawn then collect once; status is a
  one-shot snapshot, not something to poll.

TUI: collapseRepeatedStatusCard drops the previous identical swarm
status/collect card when a check repeats verbatim back-to-back, so any
residual re-checks don't stack duplicate blocks in the chat.

* fix(swarm): address review on blocking collect (race + timeout overflow)

- WaitSettled: take the settled decision and the returned snapshot in the
  SAME locked pass. Previously the snapshot was re-read after releasing the
  RLock, so a concurrent Register/Reassign could make the call return a
  non-terminal task it never waited on — breaking the blocking contract.
  Removed the now-dead scopedList; shared sortTasksByCreation with List.
- collectWaitTimeout: clamp timeout_seconds in float space before converting
  to time.Duration. A very large value overflowed the int64-ns Duration and
  wrapped negative, making context.WithTimeout fire immediately instead of
  honoring the cap.
- test: release the gated member via defer in TestCollectBlocksUntilMembersFinish
  so a failing assertion can't leak the blocked goroutine; add a
  collectWaitTimeout clamp/overflow unit test.
2026-06-24 14:48:30 +05:30

124 lines
4.7 KiB
Go

package tui
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
func TestCollapseRepeatedStatusCardDropsDuplicate(t *testing.T) {
detail := "Swarm status (team default): 4 task(s) — 4 running"
rows := []transcriptRow{
{kind: rowAssistant, text: "let me check"},
{kind: rowToolCall, tool: "swarm_status"},
{kind: rowToolResult, tool: "swarm_status", detail: detail},
{kind: rowToolCall, tool: "swarm_status"}, // the new check's call row
}
out := collapseRepeatedStatusCard(rows, transcriptRow{kind: rowToolResult, tool: "swarm_status", detail: detail})
if len(out) != 2 {
t.Fatalf("duplicate status pair should collapse to 2 rows, got %d: %+v", len(out), out)
}
if out[0].kind != rowAssistant || out[1].kind != rowToolCall {
t.Fatalf("collapse kept the wrong rows: %+v", out)
}
}
func TestCollapseRepeatedStatusCardKeepsChangedState(t *testing.T) {
rows := []transcriptRow{
{kind: rowToolCall, tool: "swarm_status"},
{kind: rowToolResult, tool: "swarm_status", detail: "4 running"},
{kind: rowToolCall, tool: "swarm_status"},
}
out := collapseRepeatedStatusCard(rows, transcriptRow{kind: rowToolResult, tool: "swarm_status", detail: "2 running, 2 done"})
if len(out) != 3 {
t.Fatalf("a changed status must not collapse, got %d rows", len(out))
}
}
func TestCollapseRepeatedStatusCardIgnoresInterveningContent(t *testing.T) {
rows := []transcriptRow{
{kind: rowToolCall, tool: "swarm_status"},
{kind: rowToolResult, tool: "swarm_status", detail: "4 running"},
{kind: rowReasoning, text: "thinking"},
{kind: rowToolCall, tool: "swarm_status"},
}
out := collapseRepeatedStatusCard(rows, transcriptRow{kind: rowToolResult, tool: "swarm_status", detail: "4 running"})
if len(out) != 4 {
t.Fatalf("intervening content must prevent collapse, got %d rows", len(out))
}
}
func TestToolResultCollapsesLongOutputByDefault(t *testing.T) {
m := transcriptViewTestModel()
long := numberedLines(cardBodyMaxLines + 10)
rc := buildRowContext(nil)
row := transcriptRow{kind: rowToolResult, id: "t1", tool: "mcp_exa_web_search_exa", status: tools.StatusOK, detail: long}
collapsed := plainRender(t, m.renderRow(row, m.width, rc))
if strings.Contains(collapsed, "line-005") {
t.Errorf("collapsed card must hide the body, got:\n%s", collapsed)
}
if !strings.Contains(collapsed, "click to expand") {
t.Errorf("collapsed card must show the expand hint, got:\n%s", collapsed)
}
row.expanded = true
expanded := plainRender(t, m.renderRow(row, m.width, rc))
if !strings.Contains(expanded, "line-005") {
t.Errorf("expanded card must show the body, got:\n%s", expanded)
}
}
func TestToolResultShortOutputStaysInline(t *testing.T) {
m := transcriptViewTestModel()
row := transcriptRow{kind: rowToolResult, id: "t2", tool: "mcp_exa_web_search_exa", status: tools.StatusOK, detail: numberedLines(3)}
out := plainRender(t, m.renderRow(row, m.width, buildRowContext(nil)))
if strings.Contains(out, "click to expand") {
t.Errorf("short output must not collapse, got:\n%s", out)
}
if !strings.Contains(out, "line-003") {
t.Errorf("short output must render inline, got:\n%s", out)
}
}
func TestDiffToolOutputNeverCollapses(t *testing.T) {
m := transcriptViewTestModel()
long := numberedLines(cardBodyMaxLines + 10)
for _, tool := range []string{"edit_file", "apply_patch", "write_file"} {
row := transcriptRow{kind: rowToolResult, id: "d", tool: tool, status: tools.StatusOK, detail: long}
out := plainRender(t, m.renderRow(row, m.width, buildRowContext(nil)))
if strings.Contains(out, "click to expand") {
t.Errorf("%s output must stay reviewable, not collapse:\n%s", tool, out)
}
}
}
func TestToggleTranscriptRowTogglesToolResult(t *testing.T) {
m := transcriptViewTestModel()
m.transcript = []transcriptRow{{kind: rowToolResult, id: "t", tool: "custom_tool"}}
if m.transcript[0].expanded {
t.Fatal("tool result must default to collapsed")
}
m = m.toggleTranscriptRow(0)
if !m.transcript[0].expanded {
t.Fatal("toggle should expand the tool result")
}
m = m.toggleTranscriptRow(0)
if m.transcript[0].expanded {
t.Fatal("toggle should collapse the tool result again")
}
}
func TestToolResultRowExposesClickToggle(t *testing.T) {
m := transcriptViewTestModel()
row := transcriptRow{kind: rowToolResult, id: "t", tool: "custom_tool", status: tools.StatusOK, detail: numberedLines(cardBodyMaxLines + 5)}
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
if len(selectable) < 1 || !selectable[0].toggle {
t.Fatalf("tool result head must be a clickable toggle line, got %#v", selectable)
}
if len(selectable) < 2 || selectable[1].text == "" {
t.Fatalf("tool result visible body/footer must stay selectable, got %#v", selectable)
}
}