zot/internal/core/core_test.go
patriceckhart b11e6ed4e4 swarm: introduce /swarm dashboard, /btw-style transcript view, and per-session scope
A /swarm subsystem for long-running parallel subagents. Each agent runs
in its own subprocess against a fresh git worktree (branch swarm/<id>)
with its own persistent session file and unix-socket inbox; the parent
zot stays in the main session and pokes / observes them via the
dashboard.

Highlights:

- New internal/swarm package: Agent, Spawn/Resume/Kill/Remove, event log
  (events.jsonl), inbox protocol (listen/dial), worktree manager, exec
  runner that spawns "zot --swarm-agent ...".
- New internal/agent/swarm_agent.go: daemon-mode child entry point.
  Reuses the standard agent loop but persists turns to the supervisor-
  chosen session.json and streams events as JSONL on stdout. Mirror to
  events.jsonl is dormant while the supervisor's stdout pipe is alive so
  events do not get double-written.
- Resume reattaches in place: reuses the same worktree, session, branch
  and inbox path; carries forward the prior transcript replayed from
  events.jsonl. Resume no longer re-fires the original Task as a fresh
  user turn -- that was producing "agent busy; send cancel first" races.
- core.NewSessionAtPath plus an openOrCreateSession fallback so the
  child actually persists its session.json at the supervisor-chosen path
  on first spawn instead of running with sess==nil.
- Dashboard in internal/agent/modes/swarm_dialog.go + swarm_slash.go:
  list / new / kill / remove / resume / logs / send subcommands plus an
  interactive picker. Transcript view is /btw-style: an always-on
  inline editor at the bottom, streaming auto-follow, inline busy
  spinner with the agent's current activity such as "thinking" or
  "tool: edit". /model inside the spawn editor pops the global model
  picker.
- Per-session scope: each spawn is stamped with the host session's id
  and only shows in that session's /swarm dashboard. Pre-upgrade agents
  -- empty session_id -- remain visible everywhere as a safety net. The
  active scope is re-applied whenever loadSession swaps sessions.
- Resolve falls back to the provider's default model when the persisted
  cfg.Model is no longer in the catalogue, warns on stderr, and rewrites
  config.json so the next launch is silent.
- ReadEventLog folds back-to-back same-type identical-payload events
  within 250ms so events.jsonl files polluted by the old supervisor +
  mirror double-write read back cleanly.
- DrawLog gains an idle no-op fast path: identical buffer plus identical
  cursor = emit nothing, so the terminal's cursor blink keeps ticking in
  dialogs whose underlying agent is idle.

Slash UX:

- New /swarm command with subcommands; the suggester picks it up.
- README.md documents the full dashboard, CLI, and persistence story,
  and explicitly notes that /session export does NOT bundle subagents
  -- their worktree and unix-socket inbox cannot round-trip through a
  .zotsession.

Tests cover: SpawnReq + Resume lifecycle, session-id scoping + persistence,
default-child-args spawn vs resume contract, NewSessionAtPath at a fixed
path, model fallback when the configured model is gone, swarm dialog
behaviour -- auto-open editor, /model in spawn editor, transcript grows
without internal scroll, busy spinner, multi-message send -- event-log
dedup, swarm emitter dormant-until-orphan, and the DrawLog idle no-op +
change-breaks-fast-path invariants.
2026-05-16 11:53:20 +02:00

107 lines
3.1 KiB
Go

package core
import (
"os"
"testing"
"time"
"github.com/patriceckhart/zot/internal/provider"
)
func TestSessionRoundTrip(t *testing.T) {
dir := t.TempDir()
os.Setenv("ZOT_HOME", dir)
sess, err := NewSession(dir, "/tmp/project", "anthropic", "claude-sonnet-4-5", "test")
if err != nil {
t.Fatal(err)
}
msg := provider.Message{
Role: provider.RoleUser,
Content: []provider.Content{
provider.TextBlock{Text: "hello"},
},
Time: time.Now().UTC(),
}
if err := sess.AppendMessage(msg); err != nil {
t.Fatal(err)
}
if err := sess.Close(); err != nil {
t.Fatal(err)
}
reopened, msgs, err := OpenSession(sess.Path)
if err != nil {
t.Fatal(err)
}
// OpenSession returns a live append writer; close it before t.TempDir
// runs cleanup, otherwise windows refuses to delete the open file.
t.Cleanup(func() { _ = reopened.Close() })
if len(msgs) != 1 {
t.Fatalf("got %d messages", len(msgs))
}
tb, ok := msgs[0].Content[0].(provider.TextBlock)
if !ok || tb.Text != "hello" {
t.Fatalf("got %+v", msgs[0])
}
}
// TestNewSessionAtPathCreatesAtExplicitPath proves the swarm child's
// session-persistence fallback works: NewSessionAtPath creates the
// file (and parent dirs) at the exact path the caller chose,
// independent of SessionsDir(root, cwd). Without this helper, the
// swarm child's --session <path> with a non-existent path would
// fall through to NewSession and write the session under a
// different (autogenerated) name, leaving the supervisor-chosen
// path empty.
func TestNewSessionAtPathCreatesAtExplicitPath(t *testing.T) {
dir := t.TempDir()
want := dir + "/nested/sub/session.json"
s, err := NewSessionAtPath(want, "/tmp/proj", "anthropic", "claude", "test")
if err != nil {
t.Fatalf("NewSessionAtPath: %v", err)
}
if s.Path != want {
t.Errorf("Path = %q; want %q", s.Path, want)
}
if err := s.AppendMessage(provider.Message{
Role: provider.RoleUser,
Content: []provider.Content{provider.TextBlock{Text: "hi"}},
Time: time.Now().UTC(),
}); err != nil {
t.Fatal(err)
}
if err := s.Close(); err != nil {
t.Fatal(err)
}
// Reopen at the same path and the message must still be there.
reopened, msgs, err := OpenSession(want)
if err != nil {
t.Fatalf("OpenSession at fixed path: %v", err)
}
t.Cleanup(func() { _ = reopened.Close() })
if len(msgs) != 1 {
t.Fatalf("reopen got %d msgs; want 1", len(msgs))
}
// A second call to NewSessionAtPath at the same path must fail
// (O_EXCL): callers should use OpenSession to reattach to an
// existing file.
if _, err := NewSessionAtPath(want, "/tmp/proj", "anthropic", "claude", "test"); err == nil {
t.Error("NewSessionAtPath on existing path returned nil; want error")
}
}
func TestCostAdd(t *testing.T) {
var c CostTracker
c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, CostUSD: 0.01})
c.Add(provider.Usage{InputTokens: 200, OutputTokens: 25, CostUSD: 0.02})
if c.Total.InputTokens != 300 || c.Total.OutputTokens != 75 {
t.Fatalf("got %+v", c.Total)
}
if c.Total.CostUSD < 0.0299 || c.Total.CostUSD > 0.0301 {
t.Fatalf("got cost %v", c.Total.CostUSD)
}
}