2026-05-09 18:37:27 +02:00
|
|
|
package agent
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
|
|
|
|
|
root := t.TempDir()
|
|
|
|
|
zotHome := filepath.Join(root, "zot-home")
|
|
|
|
|
project := filepath.Join(root, "repo")
|
|
|
|
|
nested := filepath.Join(project, "packages", "app")
|
|
|
|
|
if err := os.MkdirAll(zotHome, 0o755); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.MkdirAll(nested, 0o755); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got := readAgentsContext(nested, zotHome)
|
|
|
|
|
for _, want := range []string{"global rule", "repo rule", "app rule"} {
|
|
|
|
|
if !strings.Contains(got, want) {
|
|
|
|
|
t.Fatalf("readAgentsContext missing %q in:\n%s", want, got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") {
|
|
|
|
|
t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
|
|
|
|
|
got := readAgentsContext(t.TempDir(), t.TempDir())
|
|
|
|
|
if got != "" {
|
|
|
|
|
t.Fatalf("expected no context, got %q", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
|
|
|
|
|
// startup failure caught by the user's screenshot: the persisted
|
|
|
|
|
// config.json points at a model id that's no longer in the active
|
|
|
|
|
// catalogue (because they edited models.json or zot's bundled
|
|
|
|
|
// catalogue changed). Resolve must NOT error — strands the user
|
|
|
|
|
// with no way to fix it from the TUI — and should repair the config
|
|
|
|
|
// so the next launch is silent.
|
|
|
|
|
func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
|
|
|
|
|
t.Setenv("ZOT_HOME", t.TempDir())
|
|
|
|
|
t.Setenv("OPENAI_API_KEY", "test-key")
|
|
|
|
|
// Persist a stale model id.
|
|
|
|
|
stale := "gpt-5.5-pro-not-real"
|
|
|
|
|
if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
r, err := Resolve(Args{}, false)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Resolve refused to launch with stale model: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if r.Model == stale {
|
|
|
|
|
t.Fatalf("Resolve kept stale model %q", r.Model)
|
|
|
|
|
}
|
|
|
|
|
if r.Provider != "openai" {
|
|
|
|
|
t.Errorf("provider drifted: got %q; want openai", r.Provider)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Config on disk should now hold the fallback so subsequent
|
|
|
|
|
// launches don't repeat the warning.
|
|
|
|
|
cfg, _ := LoadConfig()
|
|
|
|
|
if cfg.Model == stale {
|
|
|
|
|
t.Errorf("config.json still pins the stale model %q", cfg.Model)
|
|
|
|
|
}
|
|
|
|
|
if cfg.Model == "" {
|
|
|
|
|
t.Errorf("config.json was emptied; expected the fallback model id")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
|
|
|
|
|
// repair-on-disk happens ONLY when the stale id came from the
|
|
|
|
|
// persisted config. If the user passed --model X explicitly and X is
|
|
|
|
|
// unknown, we still fall back, but we don't touch their config.
|
|
|
|
|
func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
|
|
|
|
|
t.Setenv("ZOT_HOME", t.TempDir())
|
|
|
|
|
t.Setenv("OPENAI_API_KEY", "test-key")
|
|
|
|
|
good := "gpt-5"
|
|
|
|
|
if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Resolve errored on unknown --model: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if r.Model == "gpt-totally-fake" {
|
|
|
|
|
t.Errorf("Resolve kept the bogus --model value")
|
|
|
|
|
}
|
|
|
|
|
cfg, _ := LoadConfig()
|
|
|
|
|
if cfg.Model != good {
|
|
|
|
|
t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-03 18:13:22 +02:00
|
|
|
|
|
|
|
|
func TestCanonicalProviderResolvesAliases(t *testing.T) {
|
|
|
|
|
cases := map[string]string{
|
|
|
|
|
"bedrock": "amazon-bedrock",
|
|
|
|
|
"AWS-Bedrock": "amazon-bedrock",
|
|
|
|
|
" bedrock ": "amazon-bedrock",
|
|
|
|
|
"vertex": "google-vertex",
|
|
|
|
|
"gemini": "google",
|
|
|
|
|
"azure": "azure-openai-responses",
|
|
|
|
|
"copilot": "github-copilot",
|
|
|
|
|
"codex": "openai-codex",
|
|
|
|
|
"moonshot": "moonshotai",
|
|
|
|
|
"vercel": "vercel-ai-gateway",
|
|
|
|
|
"hf": "huggingface",
|
|
|
|
|
"anthropic": "anthropic", // canonical passes through
|
|
|
|
|
"amazon-bedrock": "amazon-bedrock", // already canonical
|
|
|
|
|
"totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
|
|
|
|
|
"Totally-UNKNOWN": "totally-unknown",
|
|
|
|
|
"": "",
|
|
|
|
|
}
|
|
|
|
|
for in, want := range cases {
|
|
|
|
|
if got := canonicalProvider(in); got != want {
|
|
|
|
|
t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
|
|
|
|
|
for alias, canon := range providerAliases {
|
|
|
|
|
if !isKnownProvider(canon) {
|
|
|
|
|
t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|