Files
zero/internal/specialist/task_tool_test.go
Vasanth T b237e9849a feat(tui): sticky plan panel + specialist task cards + task table overlay (#255)
* feat(tui): sticky plan panel + specialist task cards + task table overlay

Three new TUI surfaces that give users live visibility into agent progress
and specialist delegation, so long turns and subagent work never look frozen.

Sticky plan panel (plan_panel.go):
- Pinned between the title bar and transcript body, shows the agent's
  step-by-step plan from the update_plan tool as a live checklist.
- Header with spinner + progress count + elapsed; text progress bar;
  per-step list with status icons (✓ completed, ⠹ in_progress, ○ pending,
  ✗ failed) and individual step timings.
- Auto-collapses to a one-line green summary 30s after completion.
  Ctrl+P toggles expand/collapse.
- Plan state synced from update_plan tool results via CurrentPlan();
  timestamps preserved across the tool's full-replacement updates by
  matching on content.

Specialist cards (specialist_card.go):
- New rowSpecialist transcript row kind; EventSpecialistStart/Stop
  now handled in session.go hydration (previously fell through to
  default and were silently dropped on resume).
- Animated card with spinner + name + task + elapsed + token count +
  tool call count. Blue while running, green when done, red on error.
- specialistTracker tracks live state; Task tool calls/returns are
  intercepted in the OnToolCall/OnToolResult callbacks to start/complete
  tracking and emit card rows.
- Height cache marks running specialist rows unstable so the spinner
  animates.

Task table overlay (task_table.go):
- Ctrl+G toggles a full-width overlay showing all specialists in the
  session as a sortable table: #, Specialist, Task, Status, Time,
  Tokens, Tools.
- Summary bar with running/completed/error counts, total tokens,
  aggregate elapsed.
- Arrow keys navigate rows; Esc closes the overlay.

Tests: plan_panel_test.go (7 tests), specialist_card_test.go (11 tests),
task_table_test.go (8 tests). All existing TUI tests pass.

* fix(update_plan): expose structured nested item schema so models stop sending bad arguments

The plan property's Items schema was omitted because of an outdated comment
claiming the serializer drops nested Properties/Required. The serializer
(propertyToRuntimeMap) already handles them recursively. Without the nested
schema, weaker models guessed the item structure from a text description and
sent malformed arguments, hitting the 4-strike guardrail halt.

Now exposes Items as an object with content (string, required), status (enum),
and notes (string).

* fix(tui): route plan + specialist updates through message sink, not stale closures

The OnToolCall/OnToolResult callbacks captured model by value, so
m.plan.updateFromItems() and m.specialists.start/complete() mutated stale
copies that never reached the live model. Introduced planUpdateMsg,
specialistStartMsg, and specialistCompleteMsg tea.Msg types sent through
runtimeMessageSink, handled in updateModel() where they mutate the live model.

* feat(tui): subchat drill-in for specialist sessions

Enter on a task table row loads the child session's events and swaps the
transcript view to show the subagent's full conversation (user messages,
agent text, tool calls, results). A nav bar at the top shows the specialist
name/task and a back hint. ArrowUp or Esc pops back to the main chat at the
saved scroll position.

- subchat.go: subchatState with enter/exit, renderSubchatNavBar
- transcript_selection.go: transcriptBodyItemsFromRows renders arbitrary
  rows (reuses the same rendering pipeline as the main transcript)
- model.go: Enter on task table row triggers drill-in; ArrowUp/Esc exits;
  transcriptView() short-circuits to child rows when subchat is active

* fix(ci): strip CRLF line endings and BOM from plan_tool_test.go and update_plan.go

gofmt and git diff --check failed on ubuntu CI because PowerShell's
Set-Content wrote UTF-8 with BOM and CRLF. Stripped both to plain LF
without BOM.

* feat(tui): add renderLeftRuleCard helper for borderless specialist cards

* feat(tui): switch specialist cards to left-rule rendering, drop hint line

* feat(tui): add renderSpecialistSummary one-line rollup for specialist cards

* fix(tui): correct slice offset in renderSpecialistSummary and add pluralization test

The muted tail was sliced at summary[len(spinnerView)] but the spinner
sits at byte offset 2 (after the 2-space indent), so the slice cut into
the spinner's UTF-8 continuation bytes and dropped the indent. Slice at
2+len(spinnerView) instead. Also add a test asserting '2 errors' is
present and '2 error' (singular) is absent when two specialists errored.

* refactor(tui): remove task table overlay — cards carry everything now

* feat(tui): render specialist summary line above cards in transcript

* feat(tui): mark specialist card lines as clickable selectable rows

* feat(tui): click specialist card to drill into subchat, Enter as keyboard fallback

* tui: clear selectedSpecialistID after Enter drill-in

Fixes Task 7 review issue: selectedSpecialistID persisted after
Enter drill-in, causing a subsequent Enter to re-drill into the
specialist's subchat instead of submitting the composer. Clear it
immediately after subchat.enter succeeds so the keyboard fallback
is one-shot per click; click re-sets it, so click->Enter still works.

* chore: gofmt model.go + test files

* chore: gitignore .superpowers/ and docs/superpowers/ — local skill artifacts

* fix(tui): remove Enter keyboard fallback for subchat drill-in — mouse click only

* fix(tui): reconcile specialist childSessionID from tool call ID to real session ID on completion

* feat(specialist): add Progress callback field to RunOptions and TaskRunOptions

* feat(specialist): stream child stdout line-by-line via Progress callback

* feat(agent): wire OnToolProgress callback for specialist child events

* feat(tui): show live tool-call progress in specialist cards

* fix(swarm): update RunChild mock signature for new progress callback param

* fix(tui): render plan panel inline in transcript, not pinned at top

* chore: gofmt model.go, specialist_card.go, specialist_click_test.go

* fix(tui): address PR review - plan panel without specialists, stream reader safety, resume dedup + payload keys
2026-06-19 20:35:08 +05:30

346 lines
12 KiB
Go

package specialist
import (
"context"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/background"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
)
func TestTaskToolRunsForegroundSpecialist(t *testing.T) {
zero := 0
var gotBinary string
var gotArgs []string
executor := Executor{
BinaryPath: "/usr/local/bin/zero",
NewSessionID: func() (string, error) { return "child_task", nil },
Load: func(LoadOptions) (LoadResult, error) {
return LoadResult{Specialists: []Manifest{{
Metadata: Metadata{
Name: "worker",
Description: "Does focused work",
Tools: []string{"read-only"},
},
SystemPrompt: "Work carefully.",
ResolvedTools: []string{"grep", "read_file"},
}}}, nil
},
RunChild: func(ctx context.Context, binaryPath string, args []string, progress func(streamjson.Event)) (ChildRunResult, error) {
gotBinary = binaryPath
gotArgs = append([]string(nil), args...)
return ChildRunResult{
Events: []streamjson.Event{
{Type: streamjson.EventRunStart, SessionID: "child_task"},
{Type: streamjson.EventFinal, Text: "child finished"},
{Type: streamjson.EventRunEnd, Status: "success", ExitCode: &zero},
},
}, nil
},
}
result := NewTaskTool(executor).RunWithOptions(context.Background(), map[string]any{
"name": "worker",
"prompt": "inspect auth",
"description": "Auth check",
}, tools.RunOptions{
ToolCallID: "call_1",
SessionID: "parent_session",
Model: "gpt-4.1",
ReasoningEffort: "medium",
Depth: 1,
Cwd: "/repo",
})
if result.Status != tools.StatusOK {
t.Fatalf("Task status = %s, output=%s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "session_id: child_task") || !strings.Contains(result.Output, "child finished") {
t.Fatalf("unexpected output: %q", result.Output)
}
if result.Meta["session_id"] != "child_task" {
t.Fatalf("session meta = %#v", result.Meta)
}
if gotBinary != "/usr/local/bin/zero" {
t.Fatalf("binary = %q", gotBinary)
}
for _, want := range [][]string{
{"exec", "--init-session-id", "child_task"},
{"--model", "gpt-4.1"},
{"--reasoning-effort", "medium"},
{"--enabled-tools", "grep,read_file"},
{"--depth", "2"},
{"--tag", "specialist"},
{"--calling-session-id", "parent_session"},
{"--calling-tool-use-id", "call_1"},
{"--session-title", "worker: Auth check"},
{"--cwd", "/repo"},
} {
if !containsSequence(gotArgs, want) {
t.Fatalf("args missing %v: %#v", want, gotArgs)
}
}
}
func TestTaskToolRunsResumeSpecialist(t *testing.T) {
var gotArgs []string
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
if _, err := store.Create(sessions.CreateInput{
SessionID: "child_task",
SessionKind: sessions.SessionKindChild,
Tag: SessionTagSpecialist,
AgentName: "worker",
}); err != nil {
t.Fatalf("Create resume session returned error: %v", err)
}
executor := Executor{
NewSessionID: func() (string, error) { return "unused", nil },
SessionStore: store,
Load: func(LoadOptions) (LoadResult, error) {
return LoadResult{Specialists: []Manifest{{
Metadata: Metadata{Name: "worker", Description: "Does focused work"},
SystemPrompt: "Work carefully.",
ResolvedTools: []string{"read_file"},
}}}, nil
},
RunChild: func(ctx context.Context, binaryPath string, args []string, progress func(streamjson.Event)) (ChildRunResult, error) {
gotArgs = append([]string(nil), args...)
return ChildRunResult{Events: []streamjson.Event{{Type: streamjson.EventFinal, Text: "resumed"}}}, nil
},
}
result := NewTaskTool(executor).RunWithOptions(context.Background(), map[string]any{
"prompt": "follow up",
"resume": "child_task",
}, tools.RunOptions{Depth: 2})
if result.Status != tools.StatusOK {
t.Fatalf("Task status = %s, output=%s", result.Status, result.Output)
}
for _, want := range [][]string{
{"exec", "--resume", "child_task"},
{"--enabled-tools", "read_file"},
{"--depth", "3"},
{"--tag", "specialist"},
} {
if !containsSequence(gotArgs, want) {
t.Fatalf("resume args missing %v: %#v", want, gotArgs)
}
}
}
func TestTaskToolRejectsBackgroundResume(t *testing.T) {
calledRunChild := false
executor := Executor{
RunChild: func(ctx context.Context, binaryPath string, args []string, progress func(streamjson.Event)) (ChildRunResult, error) {
calledRunChild = true
return ChildRunResult{}, nil
},
}
result := NewTaskTool(executor).RunWithOptions(context.Background(), map[string]any{
"prompt": "follow up",
"resume": "child_task",
"run_in_background": true,
}, tools.RunOptions{Depth: 2})
if result.Status != tools.StatusError || !strings.Contains(result.Output, "cannot run in background") {
t.Fatalf("background resume result = %#v", result)
}
if calledRunChild {
t.Fatal("RunChild was called for rejected background resume")
}
}
func TestTaskToolRejectsResumeSpecialistMismatch(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
if _, err := store.Create(sessions.CreateInput{
SessionID: "child_task",
SessionKind: sessions.SessionKindChild,
Tag: SessionTagSpecialist,
AgentName: "worker",
}); err != nil {
t.Fatalf("Create resume session returned error: %v", err)
}
result := NewTaskTool(Executor{SessionStore: store}).Run(context.Background(), map[string]any{
"name": "explorer",
"prompt": "follow up",
"resume": "child_task",
})
if result.Status != tools.StatusError || !strings.Contains(result.Output, `belongs to specialist "worker"`) {
t.Fatalf("mismatch result = %#v", result)
}
}
func TestTaskToolRunsBackgroundSpecialist(t *testing.T) {
manager, err := background.NewManager(t.TempDir())
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
var gotOutputFile string
var gotArgs []string
executor := Executor{
BinaryPath: "/usr/local/bin/zero",
BackgroundManager: manager,
NewSessionID: func() (string, error) { return "child_task", nil },
Load: func(LoadOptions) (LoadResult, error) {
return LoadResult{Specialists: []Manifest{{
Metadata: Metadata{Name: "worker", Description: "Does focused work"},
SystemPrompt: "Work carefully.",
ResolvedTools: []string{"read_file"},
}}}, nil
},
LaunchBackground: func(binaryPath string, args []string, outputFile string, onExit func(exitCode int)) (int, error) {
if binaryPath != "/usr/local/bin/zero" {
t.Fatalf("binaryPath = %q", binaryPath)
}
gotArgs = append([]string(nil), args...)
gotOutputFile = outputFile
return 4321, nil
},
}
result := NewTaskTool(executor).RunWithOptions(context.Background(), map[string]any{
"name": "worker",
"prompt": "inspect auth",
"description": "Auth check",
"run_in_background": true,
}, tools.RunOptions{SessionID: "parent_session"})
if result.Status != tools.StatusOK {
t.Fatalf("Task status = %s, output=%s", result.Status, result.Output)
}
for _, want := range []string{"Task launched in background.", "task_id: child_task", "pid: 4321", `Use TaskOutput with task_id "child_task"`} {
if !strings.Contains(result.Output, want) {
t.Fatalf("background output missing %q:\n%s", want, result.Output)
}
}
if result.Meta["task_id"] != "child_task" || result.Meta["session_id"] != "child_task" {
t.Fatalf("background meta = %#v", result.Meta)
}
if gotOutputFile != manager.OutputPath("child_task") {
t.Fatalf("output file = %q, manager path = %q", gotOutputFile, manager.OutputPath("child_task"))
}
task, ok := manager.Get("child_task")
if !ok {
t.Fatal("background task was not registered")
}
if task.Status != background.StatusRunning || task.PID != 4321 || task.ParentID != "parent_session" || task.SpecialistName != "worker" {
t.Fatalf("background task = %#v", task)
}
for _, want := range [][]string{
{"exec", "--init-session-id", "child_task"},
{"--output-format", "stream-json"},
{"--enabled-tools", "read_file"},
{"--tag", "specialist"},
} {
if !containsSequence(gotArgs, want) {
t.Fatalf("background args missing %v: %#v", want, gotArgs)
}
}
}
func TestTaskToolRejectsCanceledBackgroundBeforeRegistering(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
calledManager := false
executor := Executor{
NewSessionID: func() (string, error) { return "child_task", nil },
Load: func(LoadOptions) (LoadResult, error) {
return LoadResult{Specialists: []Manifest{{
Metadata: Metadata{Name: "worker", Description: "Does focused work"},
SystemPrompt: "Work carefully.",
ResolvedTools: []string{"read_file"},
}}}, nil
},
BackgroundManagerFunc: func() (*background.Manager, error) {
calledManager = true
return background.NewManager(t.TempDir())
},
}
result := NewTaskTool(executor).RunWithOptions(ctx, map[string]any{
"name": "worker",
"prompt": "inspect auth",
"run_in_background": true,
}, tools.RunOptions{SessionID: "parent_session"})
if result.Status != tools.StatusError || !strings.Contains(result.Output, context.Canceled.Error()) {
t.Fatalf("canceled background result = %#v", result)
}
if calledManager {
t.Fatal("background manager was created after context cancellation")
}
}
func TestTaskToolCleansPromptFileAfterChildRun(t *testing.T) {
dir := filepath.Join(t.TempDir(), "spill")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
promptFile := filepath.Join(dir, "prompt.md")
executor := Executor{
NewSessionID: func() (string, error) { return "child_task", nil },
PromptFileMaxSize: 1,
WritePromptFile: func(prompt string) (string, error) {
return promptFile, os.WriteFile(promptFile, []byte(prompt), 0o600)
},
Load: func(LoadOptions) (LoadResult, error) {
return LoadResult{Specialists: []Manifest{{
Metadata: Metadata{Name: "worker", Description: "Does focused work"},
SystemPrompt: "Work carefully.",
ResolvedTools: []string{"read_file"},
}}}, nil
},
RunChild: func(ctx context.Context, binaryPath string, args []string, progress func(streamjson.Event)) (ChildRunResult, error) {
if _, err := os.Stat(promptFile); err != nil {
t.Fatalf("prompt file should exist during child run: %v", err)
}
if !reflect.DeepEqual(args[:5], []string{"exec", "--init-session-id", "child_task", "--file", promptFile}) {
t.Fatalf("prompt file args = %#v", args[:5])
}
return ChildRunResult{Events: []streamjson.Event{{Type: streamjson.EventFinal, Text: "ok"}}}, nil
},
}
result := NewTaskTool(executor).Run(context.Background(), map[string]any{
"name": "worker",
"prompt": strings.Repeat("large ", 20),
})
if result.Status != tools.StatusOK {
t.Fatalf("Task status = %s, output=%s", result.Status, result.Output)
}
if _, err := os.Stat(promptFile); !os.IsNotExist(err) {
t.Fatalf("prompt file cleanup error = %v", err)
}
}
func TestTaskToolRejectsInvalidParameters(t *testing.T) {
result := NewTaskTool(Executor{}).Run(context.Background(), map[string]any{"name": "worker"})
if result.Status != tools.StatusError || !strings.Contains(result.Output, "prompt") {
t.Fatalf("missing prompt result = %#v", result)
}
result = NewTaskTool(Executor{}).Run(context.Background(), map[string]any{"prompt": "work"})
if result.Status != tools.StatusError || !strings.Contains(result.Output, "name or resume") {
t.Fatalf("missing name/resume result = %#v", result)
}
}
func TestTaskToolIsAdvertisedInAutoMode(t *testing.T) {
tool := NewTaskTool(Executor{})
if !agent.ToolVisible(tool, agent.PermissionModeAuto, nil, nil) {
t.Fatal("Task should be visible in auto mode so the TUI can request permission")
}
}