v0.12.0 0c5f8d5

runner_test.go RawHistoryFolder

1package subagents
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10	"testing"
11	"time"
12)
13
14// TestSubagentWorkerArgs locks in the exact flag set the subprocess
15// runner uses to start a subagent agent in daemon mode. Past
16// regressions in this area:
17//
18//   - "--no-sess" instead of "--no-session" (old print-mode runner):
19//     every spawned agent died with "unknown flag" before it could
20//     talk to the model.
21//
22//   - Forgetting --cwd: the child resolved tools against the parent
23//     zut's working directory, defeating the whole point of the
24//     worktree isolation.
25//
26//   - Forgetting --session: a daemon-mode agent without a session
27//     file would lose context between follow-up turns, making
28//     "send another message" mostly useless.
29//
30// The test asserts the load-bearing pieces are present in plausible
31// positions. If a flag is renamed, update both the runner and this
32// test so we notice immediately.
33func TestCredentialStdinHelperProcess(t *testing.T) {
34	if os.Getenv("ZUT_SUBAGENT_CREDENTIAL_HELPER") != "1" {
35		return
36	}
37	if os.Getenv("ZUT_SUBAGENT_CREDENTIAL_STDIN") != "1" {
38		os.Exit(2)
39	}
40	var credential Credential
41	if err := json.NewDecoder(os.Stdin).Decode(&credential); err != nil {
42		os.Exit(3)
43	}
44	if credential.Value != "inherited-secret" || credential.Method != "apikey" {
45		os.Exit(4)
46	}
47	for _, arg := range os.Args {
48		if strings.Contains(arg, credential.Value) {
49			os.Exit(5)
50		}
51	}
52	for _, env := range os.Environ() {
53		if strings.Contains(env, credential.Value) {
54			os.Exit(6)
55		}
56	}
57	fmt.Println(`{"type":"agent_stopped","data":{"reason":"completed"}}`)
58	os.Exit(0)
59}
60
61// TestMessageDeltaStreamsAndReplaysWithoutDuplicatingFinalMessage verifies
62// that the durable event stream has the same visible result while live and
63// after history replay.
64func TestMessageDeltaStreamsAndReplaysWithoutDuplicatingFinalMessage(t *testing.T) {
65	events := []Event{
66		NewEvent("message.delta", map[string]any{"delta": "partial "}),
67		NewEvent("message.delta", map[string]any{"delta": "answer"}),
68		NewEvent("assistant_message", map[string]any{"content": []any{
69			map[string]any{"type": "text", "text": "partial answer"},
70		}}),
71	}
72
73	live := &Agent{transcriptLoaded: true}
74	for _, event := range events {
75		applyEventToSink(event, agentSink{a: live})
76	}
77	if got := strings.Join(live.Transcript(), "\n"); got != "partial answer" {
78		t.Fatalf("live transcript = %q, want final assistant message once", got)
79	}
80	if got := live.Snapshot().LastAssistant; got != "partial answer" {
81		t.Fatalf("live assistant output = %q, want %q", got, "partial answer")
82	}
83
84	replayed := &Agent{transcriptLoaded: true}
85	for _, event := range events {
86		replayEventTranscript(replayed, event)
87	}
88	if got := strings.Join(replayed.Transcript(), "\n"); got != "partial answer" {
89		t.Fatalf("replayed transcript = %q, want final assistant message once", got)
90	}
91}
92
93func TestMessageDeltaBoundsLiveProjection(t *testing.T) {
94	a := &Agent{maxOutputBytes: 32, maxOutputLines: 100, transcriptLoaded: true}
95	applyEventToSink(NewEvent("message.delta", map[string]any{"delta": strings.Repeat("x", 64)}), agentSink{a: a})
96	applyEventToSink(NewEvent("message.delta", map[string]any{"delta": "discarded"}), agentSink{a: a})
97
98	a.mu.Lock()
99	streamed := a.streamingAssistantText
100	truncated := a.streamingAssistantTruncated
101	a.mu.Unlock()
102	if !truncated {
103		t.Fatal("streamed output was not marked truncated")
104	}
105	if got := len([]byte(streamed)); got > 32 {
106		t.Fatalf("streamed output length = %d, want at most 32", got)
107	}
108	snapshot := a.Snapshot()
109	if got := snapshot.LastAssistant; got != streamed {
110		t.Fatalf("live assistant output = %q, want bounded stream %q", got, streamed)
111	}
112	if !snapshot.OutputTruncated {
113		t.Fatal("snapshot did not expose truncated streamed output")
114	}
115
116	events := []Event{
117		NewEvent(EventMessageDelta, map[string]any{"delta": "first partial"}),
118		NewEvent("turn_end", map[string]any{"stop": "cancelled"}),
119		NewEvent(EventMessageDelta, map[string]any{"delta": "second partial"}),
120	}
121	want := "first partial\nsecond partial"
122	assertProjection := func(t *testing.T, agent *Agent) {
123		t.Helper()
124		if got := strings.Join(agent.Transcript(), "\n"); got != want {
125			t.Fatalf("transcript = %q, want %q", got, want)
126		}
127		agent.mu.Lock()
128		truncated := agent.streamingAssistantTruncated
129		agent.mu.Unlock()
130		if truncated {
131			t.Fatal("fresh second-turn stream was incorrectly truncated")
132		}
133	}
134
135	live := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
136	for _, event := range events {
137		applyEventToSink(event, agentSink{a: live})
138	}
139	assertProjection(t, live)
140
141	replayed := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
142	replayTranscriptIntoAgent(replayed, events)
143	assertProjection(t, replayed)
144
145	legacy := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
146	replayEventsIntoAgent(legacy, events)
147	assertProjection(t, legacy)
148
149	truncatedTurn := &Agent{maxOutputBytes: 16, maxOutputLines: 100, transcriptLoaded: true}
150	applyEventToSink(NewEvent(EventMessageDelta, map[string]any{"delta": strings.Repeat("x", 64)}), agentSink{a: truncatedTurn})
151	applyEventToSink(NewEvent("turn_end", map[string]any{"stop": "cancelled"}), agentSink{a: truncatedTurn})
152	truncatedTurn.mu.Lock()
153	streamText := truncatedTurn.streamingAssistantText
154	streamTruncated := truncatedTurn.streamingAssistantTruncated
155	truncatedTurn.mu.Unlock()
156	if streamText != "" || streamTruncated {
157		t.Fatalf("stream state after turn boundary = (%q, %v), want empty and not truncated", streamText, streamTruncated)
158	}
159}
160
161func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
162	t.Setenv("OPENAI_API_KEY", "openai-secret")
163	t.Setenv("CUSTOM_PROVIDER_API_KEY", "custom-secret")
164	t.Setenv("HF_TOKEN", "hf-secret")
165	t.Setenv("GITHUB_TOKEN", "github-secret")
166	t.Setenv("CUSTOM_PROVIDER_TOKEN", "token-secret")
167	t.Setenv("CUSTOM_PROVIDER_KEY", "key-secret")
168	t.Setenv("AWS_ACCESS_KEY_ID", "aws-access-secret")
169	t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/service-account.json")
170	t.Setenv("ZUT_SUBAGENT_TEST_VALUE", "not-secret")
171	env := strings.Join(workerEnvironment("openai"), "\n")
172	for _, secret := range []string{
173		"OPENAI_API_KEY=openai-secret",
174		"CUSTOM_PROVIDER_API_KEY=custom-secret",
175		"HF_TOKEN=hf-secret",
176		"GITHUB_TOKEN=github-secret",
177		"CUSTOM_PROVIDER_TOKEN=token-secret",
178		"CUSTOM_PROVIDER_KEY=key-secret",
179		"AWS_ACCESS_KEY_ID=aws-access-secret",
180		"GOOGLE_APPLICATION_CREDENTIALS=/tmp/service-account.json",
181	} {
182		if strings.Contains(env, secret) {
183			t.Fatalf("worker environment leaked %s", secret)
184		}
185	}
186	if !strings.Contains(env, "ZUT_SUBAGENT_TEST_VALUE=not-secret") {
187		t.Fatal("worker environment dropped unrelated variables")
188	}
189
190	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-secret")
191	bedrockEnv := strings.Join(workerEnvironment("amazon-bedrock"), "\n")
192	if !strings.Contains(bedrockEnv, "AWS_BEARER_TOKEN_BEDROCK=bedrock-secret") {
193		t.Fatal("Bedrock ambient credential chain was unexpectedly removed")
194	}
195	if !strings.Contains(bedrockEnv, "AWS_ACCESS_KEY_ID=aws-access-secret") {
196		t.Fatal("Bedrock AWS access-key credential was unexpectedly removed")
197	}
198}
199
200func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) {
201	t.Setenv("ZUT_SUBAGENT_CREDENTIAL_HELPER", "1")
202	root := t.TempDir()
203	a := &Agent{
204		ID:           "credential-test",
205		Dir:          root,
206		Provider:     "custom",
207		SessionPath:  filepath.Join(root, "session.json"),
208		InboxPath:    filepath.Join(root, "inbox"),
209		EventLogPath: filepath.Join(root, "events.jsonl"),
210	}
211	calls := 0
212	r := &execRunner{
213		agent:   a,
214		Command: []string{os.Args[0], "-test.run=^TestCredentialStdinHelperProcess$"},
215		resolveCredential: func(context.Context, string) (Credential, error) {
216			calls++
217			return Credential{Value: "inherited-secret", Method: "apikey"}, nil
218		},
219	}
220	if err := r.Run(context.Background(), agentSink{a: a}); err != nil {
221		t.Fatal(err)
222	}
223	if calls != 1 {
224		t.Fatalf("credential resolver called %d times, want 1", calls)
225	}
226}
227
228func TestApplyEventPreservesMultilineRolesAndFinalAssistant(t *testing.T) {
229	a := &Agent{}
230	sink := agentSink{a: a}
231	applyEventToSink(NewEvent("user_message", map[string]any{"content": []any{
232		map[string]any{"type": "text", "text": "first line\n\nlast line"},
233	}}), sink)
234	applyEventToSink(NewEvent("assistant_message", map[string]any{"content": []any{
235		map[string]any{"type": "text", "text": "complete\nanswer"},
236	}}), sink)
237
238	snap := a.Snapshot()
239	wantLines := []string{"user: first line", "user: ", "user: last line", "complete", "answer"}
240	if strings.Join(snap.Lines, "\n") != strings.Join(wantLines, "\n") {
241		t.Fatalf("transcript = %#v, want %#v", snap.Lines, wantLines)
242	}
243	if snap.LastAssistant != "complete\nanswer" {
244		t.Fatalf("last assistant = %q", snap.LastAssistant)
245	}
246}
247
248func TestSubagentWorkerArgs(t *testing.T) {
249	args := subagentWorkerArgs(subagentWorkerArgsOpts{
250		Exe:         "/path/to/zut",
251		Dir:         "/tmp/worktree",
252		SessionPath: "/tmp/state/session.json",
253		InboxPath:   "/tmp/state/in.sock",
254		Task:        "do the thing",
255		Reasoning:   "high",
256		Subagent:    "reviewer",
257	})
258	if len(args) < 7 {
259		t.Fatalf("argv unexpectedly short: %v", args)
260	}
261	if args[0] != "/path/to/zut" {
262		t.Fatalf("argv[0] = %q; want the binary path", args[0])
263	}
264	// The task must come last so anything that looks flag-like in
265	// the task body doesn't get interpreted as a flag.
266	if args[len(args)-1] != "do the thing" {
267		t.Fatalf("task should be last positional; got %v", args)
268	}
269
270	mustHave := map[string]string{
271		"--subagent-worker": "/tmp/state/in.sock",
272		"--session":         "/tmp/state/session.json",
273		"--cwd":             "/tmp/worktree",
274		"--reasoning":       "high",
275		"--subagent":        "reviewer",
276	}
277	for flag, value := range mustHave {
278		i := indexOf(args, flag)
279		if i < 0 {
280			t.Errorf("argv missing %q: %v", flag, args)
281			continue
282		}
283		if i+1 >= len(args) || args[i+1] != value {
284			t.Errorf("argv %q value = %q; want %q", flag, safeAt(args, i+1), value)
285		}
286	}
287
288	// Reject prior bad flags explicitly so a future revert is caught.
289	joined := strings.Join(args, " ")
290	for _, bad := range []string{"--print", "--no-sess ", "--no-session"} {
291		if strings.Contains(joined, bad) {
292			t.Fatalf("argv contains stale/wrong flag %q: %s", bad, joined)
293		}
294	}
295}
296
297func TestDefaultChildArgsPropagatesWebSearchPolicy(t *testing.T) {
298	allow := defaultChildArgs("zut", &Agent{Task: "task", WebSearchPolicy: WebSearchAllow}, "/session", "/inbox")
299	idx := indexOf(allow, "--web-search-policy")
300	if idx < 0 || safeAt(allow, idx+1) != "allow" {
301		t.Fatalf("allow argv = %v", allow)
302	}
303
304	// A legacy/hand-built Agent has no resolved parent policy and must not
305	// inherit the child process's default-enabled normal CLI setting.
306	deny := defaultChildArgs("zut", &Agent{Task: "task"}, "/session", "/inbox")
307	idx = indexOf(deny, "--web-search-policy")
308	if idx < 0 || safeAt(deny, idx+1) != "deny" {
309		t.Fatalf("deny argv = %v", deny)
310	}
311}
312
313func TestResolveSubagentExecutableUsesCurrentWhenAvailable(t *testing.T) {
314	got, err := resolveSubagentExecutable(context.Background(), "/current/bin/zut", "/old/bin/zut", func(_ context.Context, candidate string) (string, error) {
315		if candidate == "/current/bin/zut" {
316			return candidate, nil
317		}
318		return "", os.ErrNotExist
319	})
320	if err != nil {
321		t.Fatal(err)
322	}
323	if got != "/current/bin/zut" {
324		t.Fatalf("executable = %q, want current executable", got)
325	}
326}
327
328func TestResolveSubagentExecutableFallsBackToPath(t *testing.T) {
329	got, err := resolveSubagentExecutable(context.Background(), "/removed/.local/bin/zut", "/removed/.local/bin/zut", func(_ context.Context, candidate string) (string, error) {
330		if candidate == "zut" {
331			return "/active/go/bin/zut", nil
332		}
333		return "", os.ErrNotExist
334	})
335	if err != nil {
336		t.Fatal(err)
337	}
338	if got != "/active/go/bin/zut" {
339		t.Fatalf("executable = %q, want PATH fallback", got)
340	}
341}
342
343func TestResolveSubagentExecutableCancelsOtherLookupsAfterFirstSuccess(t *testing.T) {
344	slowStarted := make(chan struct{})
345	slowCanceled := make(chan struct{})
346	got, err := resolveSubagentExecutable(context.Background(), "/current/bin/zut", "/old/bin/zut", func(ctx context.Context, candidate string) (string, error) {
347		switch candidate {
348		case "/current/bin/zut":
349			close(slowStarted)
350			<-ctx.Done()
351			close(slowCanceled)
352			return "", ctx.Err()
353		case "zut":
354			<-slowStarted
355			return "/active/go/bin/zut", nil
356		default:
357			return "", os.ErrNotExist
358		}
359	})
360	if err != nil {
361		t.Fatal(err)
362	}
363	if got != "/active/go/bin/zut" {
364		t.Fatalf("executable = %q, want first successful lookup", got)
365	}
366	select {
367	case <-slowCanceled:
368	case <-time.After(time.Second):
369		t.Fatal("slower lookup was not canceled")
370	}
371}
372
373func TestSubagentWorkerArgsPropagatesProviderConnectionSettings(t *testing.T) {
374	args := subagentWorkerArgs(subagentWorkerArgsOpts{
375		Exe:         "/zut",
376		Dir:         "/work",
377		SessionPath: "/state/session.json",
378		InboxPath:   "/state/inbox.sock",
379		BaseURL:     "https://gateway.example.test/v1",
380		InsecureTLS: true,
381	})
382	if i := indexOf(args, "--base-url"); i < 0 || safeAt(args, i+1) != "https://gateway.example.test/v1" {
383		t.Fatalf("argv = %v, want --base-url with inherited endpoint", args)
384	}
385	if !containsArg(args, "--insecure") {
386		t.Fatalf("argv = %v, want inherited --insecure", args)
387	}
388	for _, arg := range args {
389		if strings.Contains(arg, "api-key") || strings.Contains(arg, "secret") {
390			t.Fatalf("argv contains credential material: %v", args)
391		}
392	}
393}
394
395func TestDefaultChildArgsPropagatesProviderConnectionSettings(t *testing.T) {
396	a := &Agent{
397		Dir:         "/work",
398		BaseURL:     "https://gateway.example.test/v1",
399		InsecureTLS: true,
400	}
401	args := defaultChildArgs("/zut", a, "/state/session.json", "/state/inbox.sock")
402	if i := indexOf(args, "--base-url"); i < 0 || safeAt(args, i+1) != a.BaseURL {
403		t.Fatalf("argv = %v, want Agent.BaseURL propagated", args)
404	}
405	if !containsArg(args, "--insecure") {
406		t.Fatalf("argv = %v, want Agent.InsecureTLS propagated", args)
407	}
408}
409
410// TestSubagentWorkerArgsEmptyTaskOmitsPositional makes sure that when the
411// agent is being adopted (no fresh task) we don't pass an empty
412// positional which the arg parser would treat as a real prompt.
413func TestSubagentWorkerArgsEmptyTaskOmitsPositional(t *testing.T) {
414	args := subagentWorkerArgs(subagentWorkerArgsOpts{
415		Exe: "/zut", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock",
416	})
417	for _, a := range args {
418		if a == "" {
419			t.Fatalf("argv contains an empty positional: %v", args)
420		}
421	}
422	// last arg should be a real flag value, not a stray positional
423	if a := args[len(args)-1]; strings.HasPrefix(a, "--") {
424		t.Fatalf("argv ends on a flag with no value: %v", args)
425	}
426	for _, flag := range []string{"--reasoning", "--subagent"} {
427		if indexOf(args, flag) >= 0 {
428			t.Fatalf("empty child options should omit %s: %v", flag, args)
429		}
430	}
431}
432
433// TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a
434// fresh (non-resuming) Agent produces argv that ends with the
435// original task as a positional, so the child runs it as the
436// initial user turn.
437func TestDefaultChildArgsSpawnIncludesTask(t *testing.T) {
438	a := &Agent{Dir: "/wt", Task: "do thing"}
439	args := defaultChildArgs("/zut", a, "/s.json", "/in.sock")
440	if got := args[len(args)-1]; got != "do thing" {
441		t.Fatalf("spawn argv last = %q; want %q\n%v", got, "do thing", args)
442	}
443}
444
445// TestDefaultChildArgsResumeOmitsTask is the regression for the
446// "agent busy; send 'cancel' first" error: when an Agent is being
447// resumed (Resuming==true), the child argv MUST NOT include the
448// original Task as a positional. Otherwise the child fires the task
449// as a fresh user turn on every resume, racing with whatever the
450// user types next via the inbox.
451func TestDefaultChildArgsResumeOmitsTask(t *testing.T) {
452	a := &Agent{Dir: "/wt", Task: "do thing", Resuming: true}
453	args := defaultChildArgs("/zut", a, "/s.json", "/in.sock")
454	for _, v := range args {
455		if v == "do thing" {
456			t.Fatalf("resume argv contains the task; it would re-fire as a duplicate turn\n%v", args)
457		}
458	}
459	// And no trailing positional at all: every final token must be a
460	// recognized boolean flag or a value belonging to a preceding flag,
461	// never a stray empty string or the original task.
462	if got := args[len(args)-1]; got == "" {
463		t.Fatalf("resume argv ends with an empty positional: %v", args)
464	}
465}
466
467func TestDefaultChildArgsResumeUsesFollowUpPrompt(t *testing.T) {
468	a := &Agent{
469		Dir:      "/wt",
470		Task:     "review the change",
471		Resuming: true,
472	}
473	const followUp = "I applied your feedback. Please review it again."
474	a.setResumePrompt(followUp, time.Now())
475	args := defaultChildArgs("/zut", a, "/s.json", "/in.sock")
476	if got := args[len(args)-1]; got != followUp {
477		t.Fatalf("resume argv last = %q, want follow-up prompt %q\n%v", got, followUp, args)
478	}
479	for _, v := range args[:len(args)-1] {
480		if v == a.Task {
481			t.Fatalf("resume argv contains original task; it would duplicate the first turn\n%v", args)
482		}
483	}
484}
485
486func TestReadyDoesNotMarkInitialDelegatedTurnIdle(t *testing.T) {
487	a := &Agent{Task: "initial delegated task"}
488	a.setTurnState(TurnQueued, "")
489
490	if err := updateAgentFromEvent(a, NewEvent(EventAgentReady, map[string]any{"lifetime_turns": 0, "current_run_turns": 0})); err != nil {
491		t.Fatal(err)
492	}
493	if got := a.TurnState(); got != TurnQueued {
494		t.Fatalf("ready changed turn state to %s, want %s", got, TurnQueued)
495	}
496	if err := updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{
497		"turn_id":           "turn-1",
498		"lifetime_turns":    1,
499		"current_run_turns": 1,
500	})); err != nil {
501		t.Fatal(err)
502	}
503	if got := a.TurnState(); got != TurnRunning {
504		t.Fatalf("turn.started changed turn state to %s, want %s", got, TurnRunning)
505	}
506}
507
508func TestUpdateAgentFromEventIgnoresStaleRejectedFollowUp(t *testing.T) {
509	a := &Agent{}
510	a.setResumePrompt("current prompt", time.Now())
511	if err := updateAgentFromEvent(a, NewEvent("error", map[string]any{
512		"code": "turn_rejected", "reason": "busy", "command_id": "stale-command",
513	})); err != nil {
514		t.Fatal(err)
515	}
516	if prompt, _ := a.ResumePromptInfo(); prompt != "current prompt" {
517		t.Fatalf("stale rejection changed active prompt to %q", prompt)
518	}
519}
520
521func TestUpdateAgentFromEventRecoversRejectedFollowUp(t *testing.T) {
522	a := &Agent{}
523	a.setResumePrompt("retry me", time.Now())
524	a.setTurnState(TurnQueued, "turn-1")
525	if err := updateAgentFromEvent(a, NewEvent("error", map[string]any{
526		"code":       "turn_rejected",
527		"reason":     "busy",
528		"command_id": a.resumePromptCommandID(),
529	})); err != nil {
530		t.Fatal(err)
531	}
532	if got := a.TurnState(); got != TurnIdle {
533		t.Fatalf("rejected turn state = %s, want %s", got, TurnIdle)
534	}
535	if prompt, _ := a.ResumePromptInfo(); prompt != "" {
536		t.Fatalf("rejected prompt remained active: %q", prompt)
537	}
538	queued := a.resumePromptQueueSnapshot()
539	if len(queued) != 1 || queued[0].Prompt != "retry me" {
540		t.Fatalf("rejected prompt queue = %+v, want retry me", queued)
541	}
542}
543
544func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {
545	a := &Agent{}
546	a.setTurnState(TurnRunning, "turn-1")
547	persisted := 0
548	a.persistFn = func(*Agent) error { persisted++; return nil }
549
550	updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"}))
551
552	if got := a.TurnState(); got != TurnRunning {
553		t.Fatalf("turn state = %q, want %q", got, TurnRunning)
554	}
555	if got := a.CurrentTurnID(); got != "turn-1" {
556		t.Fatalf("turn ID = %q, want turn-1", got)
557	}
558	if persisted != 0 {
559		t.Fatalf("persist called %d times for provider/tool-loop turn_end, want 0", persisted)
560	}
561}
562
563func TestUpdateAgentFromEventPersistsTerminalTurnEndWithStep(t *testing.T) {
564	a := &Agent{}
565	persisted := 0
566	a.persistFn = func(*Agent) error { persisted++; return nil }
567
568	updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
569
570	if got := a.TurnState(); got != TurnFailed {
571		t.Fatalf("turn state = %q, want %q", got, TurnFailed)
572	}
573	if persisted != 1 {
574		t.Fatalf("persist called %d times for terminal turn_end, want 1", persisted)
575	}
576}
577
578func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) {
579	called := make(chan struct{}, 1)
580	a := &Agent{}
581	a.SetOnTurnEnd(func(step int, errMsg string) {
582		called <- struct{}{}
583	})
584
585	notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"}))
586
587	select {
588	case <-called:
589		t.Fatal("OnTurnEnd fired for provider/tool-loop turn_end without step")
590	case <-time.After(50 * time.Millisecond):
591	}
592}
593
594func TestNotifyPromptTurnEndFiresForDaemonPromptCompletion(t *testing.T) {
595	type got struct {
596		step int
597		err  string
598	}
599	called := make(chan got, 1)
600	a := &Agent{}
601	a.SetOnTurnEnd(func(step int, errMsg string) {
602		called <- got{step: step, err: errMsg}
603	})
604
605	notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
606
607	select {
608	case g := <-called:
609		if g.step != 2 || g.err != "boom" {
610			t.Fatalf("callback = (%d, %q); want (2, boom)", g.step, g.err)
611		}
612	case <-time.After(time.Second):
613		t.Fatal("OnTurnEnd did not fire for prompt-level turn_end with step")
614	}
615}
616
617func TestSetOnTurnEndReplaysPendingNoticesInArrivalOrder(t *testing.T) {
618	a := &Agent{}
619	notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(1)}))
620	notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
621
622	type notice struct {
623		step int
624		err  string
625	}
626	var got []notice
627	a.SetOnTurnEnd(func(step int, errMsg string) {
628		got = append(got, notice{step: step, err: errMsg})
629	})
630
631	want := []notice{{step: 1}, {step: 2, err: "boom"}}
632	if len(got) != len(want) {
633		t.Fatalf("replayed %d notices, want %d: %+v", len(got), len(want), got)
634	}
635	for i := range want {
636		if got[i] != want[i] {
637			t.Errorf("replayed notice %d = %+v, want %+v", i, got[i], want[i])
638		}
639	}
640}
641
642func TestUpdateAgentFromEventDoesNotCountProviderTurnStarts(t *testing.T) {
643	a := &Agent{}
644	a.setTurnCounts(4, 2)
645	a.setTurnState(TurnRunning, "turn-1")
646	persisted := 0
647	a.persistFn = func(*Agent) error { persisted++; return nil }
648
649	updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{"step": float64(7), "nested_turn": true}))
650
651	if got := a.LifetimeTurnsValue(); got != 4 {
652		t.Fatalf("lifetime turns = %d, want 4", got)
653	}
654	if got := a.CurrentRunTurnsValue(); got != 2 {
655		t.Fatalf("current-run turns = %d, want 2", got)
656	}
657	if got := a.TurnState(); got != TurnRunning {
658		t.Fatalf("turn state = %s, want %s", got, TurnRunning)
659	}
660	if persisted != 0 {
661		t.Fatalf("provider turn start persisted %d times, want 0", persisted)
662	}
663}
664
665func TestUpdateAgentFromEventPersistsCanonicalTurnCounters(t *testing.T) {
666	stateDir := t.TempDir()
667	a := &Agent{ID: "counter-agent", stateDir: stateDir}
668	a.persistFn = func(agent *Agent) error {
669		if err := writeAgentMeta(stateDir, agent); err != nil {
670			t.Errorf("write agent metadata: %v", err)
671			return err
672		}
673		return nil
674	}
675
676	updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{
677		"turn_id":           "turn-3",
678		"lifetime_turns":    3,
679		"current_run_turns": 1,
680	}))
681
682	meta, err := readAgentMeta(stateDir)
683	if err != nil {
684		t.Fatal(err)
685	}
686	if meta.LifetimeTurns != 3 || meta.CurrentRunTurns != 1 {
687		t.Fatalf("persisted counters = (%d, %d), want (3, 1)", meta.LifetimeTurns, meta.CurrentRunTurns)
688	}
689	if got := a.Snapshot(); got.LifetimeTurns != 3 || got.CurrentRunTurns != 1 {
690		t.Fatalf("snapshot counters = (%d, %d), want (3, 1)", got.LifetimeTurns, got.CurrentRunTurns)
691	}
692}
693
694func TestRecordPersistenceErrorPreservesFirstFailure(t *testing.T) {
695	a := &Agent{}
696	a.recordPersistenceError(fmt.Errorf("first failure"))
697	a.recordPersistenceError(fmt.Errorf("later failure"))
698	if got := a.Snapshot().Err; !strings.Contains(got, "first failure") || strings.Contains(got, "later failure") {
699		t.Fatalf("recorded persistence error = %q, want only first failure", got)
700	}
701}
702
703func TestRestoreResumePromptPreservesCommandIdentity(t *testing.T) {
704	a := &Agent{}
705	a.setResumePrompt("old prompt", time.Now())
706	oldID := a.resumePromptCommandID()
707	previous := a.setResumePrompt("new prompt", time.Now())
708	a.restoreResumePrompt(previous)
709	if got := a.resumePromptCommandID(); got != oldID {
710		t.Fatalf("restored command ID = %q, want %q", got, oldID)
711	}
712}
713
714func TestEventMatchesPendingResumeRejectsIdentityLessRejection(t *testing.T) {
715	a := &Agent{resumePromptID: "command-1"}
716	if eventMatchesPendingResume(a, Event{Type: "error", Data: map[string]any{"code": "turn_rejected"}}) {
717		t.Fatal("identity-less turn rejection matched a pending prompt")
718	}
719	if !eventMatchesPendingResume(a, Event{Type: EventTurnStarted, Data: map[string]any{}}) {
720		t.Fatal("identity-less legacy turn.started did not match a pending prompt")
721	}
722	if !eventMatchesPendingResume(a, Event{Type: "error", Data: map[string]any{"code": "turn_rejected", "command_id": "command-1"}}) {
723		t.Fatal("matching turn rejection did not match a pending prompt")
724	}
725}
726
727func TestEventCounterRejectsNegativeFractionalAndOverflowValues(t *testing.T) {
728	cases := []struct {
729		name  string
730		value any
731		ok    bool
732	}{
733		{name: "valid integer", value: float64(3), ok: true},
734		{name: "negative integer", value: int64(-1)},
735		{name: "fractional float", value: 1.5},
736		{name: "negative float", value: -1.0},
737		{name: "fractional json number", value: json.Number("1.5")},
738		{name: "overflow float", value: float64(^uint(0) >> 1)},
739	}
740	for _, tc := range cases {
741		t.Run(tc.name, func(t *testing.T) {
742			got, ok := eventCounter(map[string]any{"counter": tc.value}, "counter")
743			if ok != tc.ok {
744				t.Fatalf("eventCounter ok = %t, want %t (value %v, got %d)", ok, tc.ok, tc.value, got)
745			}
746		})
747	}
748}
749
750func indexOf(xs []string, want string) int {
751	for i, x := range xs {
752		if x == want {
753			return i
754		}
755	}
756	return -1
757}
758
759func safeAt(xs []string, i int) string {
760	if i < 0 || i >= len(xs) {
761		return ""
762	}
763	return xs[i]
764}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close