mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* 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.
124 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|