package agent import ( "bytes" "context" "os" "path/filepath" "strings" "testing" "time" "github.com/johannSo/codey/internal/llm" "github.com/johannSo/codey/internal/metrics" "github.com/johannSo/codey/internal/tools" ) // newInterruptibleAgent wires an agent whose "wait" tool blocks until the // context is cancelled, which is what pressing esc does to a turn. func newInterruptibleAgent(t *testing.T, f *pausableAPI, root string, started chan<- struct{}) *Agent { t.Helper() log := metrics.NewWriter(&bytes.Buffer{}, "test") state := NewState() reg := tools.NewRegistry(log).Register( tools.NewRead(root, state), tools.New("wait", "Blocks until the turn is cancelled", func(ctx context.Context, _ struct{}) (string, error) { started <- struct{}{} <-ctx.Done() return "", ctx.Err() }), ) client := llm.New(llm.WithBaseURL(f.srv.URL), llm.WithAPIKey("k")) return New(client, reg, state, log, Options{}) } // unanswered returns the tool_use ids in msgs that no tool_result answers. The // API rejects the whole request over even one of them, so this is the only // property that matters about an interrupted conversation. func unanswered(msgs []llm.Message) []string { answered := map[string]bool{} for _, m := range msgs { for _, b := range m.Content { if b.Type == llm.BlockToolResult { answered[b.ToolUseID] = true } } } var open []string for _, m := range msgs { for _, b := range m.Content { if b.Type == llm.BlockToolUse && !answered[b.ID] { open = append(open, b.ID) } } } return open } // Interrupting a turn while a tool is in flight must still leave a conversation // the API will accept: the assistant message asking for the call is already in // the history, and a tool_use with no tool_result is a 400 on every request // that follows — the next prompt, or a --resume weeks later. func TestInterruptedTurnAnswersEveryToolCall(t *testing.T) { root := t.TempDir() os.WriteFile(filepath.Join(root, "a.go"), []byte("A\n"), 0o644) f := newPausableAPI(t, msg("tool_use", toolUseBlock("tu_1", "read_file", `{"path":"a.go"}`), toolUseBlock("tu_2", "wait", `{}`)), msg("end_turn", textBlock("ok")), ) started := make(chan struct{}, 1) a := newInterruptibleAgent(t, f, root, started) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { _, err := a.Run(ctx, "go") done <- err }() select { case <-started: case <-time.After(2 * time.Second): t.Fatal("the blocking tool never started") } cancel() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("the cancelled run never returned") } h := a.State().History() if open := unanswered(h); len(open) != 0 { t.Fatalf("after an interrupt these tool_use ids have no tool_result: %v", open) } last := h[len(h)-1] if last.Role != llm.RoleUser || len(last.Content) != 2 { t.Fatalf("last message = %+v, want one user message holding both results", last) } // The call that did finish keeps what it returned: an interrupt throws // away the turn, not the work the turn already did. if got := last.Content[0]; got.ToolUseID != "tu_1" || got.IsError || !strings.Contains(got.Content, "A") { t.Errorf("result for the finished read = %+v, want its contents", got) } if got := last.Content[1]; got.ToolUseID != "tu_2" || !got.IsError || !strings.Contains(got.Content, "interrupted") { t.Errorf("result for the interrupted call = %+v, want an error saying it was interrupted", got) } } // The repaired conversation is not merely well formed in memory: the next // prompt in the same session has to reach the API. func TestTheTurnAfterAnInterruptIsSent(t *testing.T) { root := t.TempDir() os.WriteFile(filepath.Join(root, "a.go"), []byte("A\n"), 0o644) f := newPausableAPI(t, msg("tool_use", toolUseBlock("tu_1", "wait", `{}`)), msg("end_turn", textBlock("ok")), ) started := make(chan struct{}, 1) a := newInterruptibleAgent(t, f, root, started) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { a.Run(ctx, "go") close(done) }() select { case <-started: case <-time.After(2 * time.Second): t.Fatal("the blocking tool never started") } cancel() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("the cancelled run never returned") } if _, err := a.Run(context.Background(), "continue pls"); err != nil { t.Fatalf("the prompt after an interrupt failed: %v", err) } if open := unanswered(f.lastRequest(t).Messages); len(open) != 0 { t.Errorf("the request sent after an interrupt left these tool_use ids unanswered: %v", open) } } // A session saved while a turn was in flight is on disk with the dangling // tool_use in it. Reopening it must repair the history rather than hand the // user a 400 on their first word. func TestRestoreClosesADanglingToolUse(t *testing.T) { f := newPausableAPI(t, msg("end_turn", textBlock("ok"))) a := newInterruptibleAgent(t, f, t.TempDir(), make(chan struct{}, 1)) a.Restore(Session{Messages: []llm.Message{ {Role: llm.RoleUser, Content: []llm.ContentBlock{{Type: llm.BlockText, Text: "go"}}}, {Role: llm.RoleAssistant, Content: []llm.ContentBlock{ {Type: llm.BlockToolUse, ID: "tu_1", Name: "wait", Input: []byte(`{}`)}, }}, }}) h := a.State().History() if open := unanswered(h); len(open) != 0 { t.Fatalf("restoring left these tool_use ids unanswered: %v", open) } if last := h[len(h)-1]; last.Role != llm.RoleUser || last.Content[0].ToolUseID != "tu_1" { t.Errorf("last message = %+v, want a tool_result for tu_1", last) } } // A hosted web search is answered by the API itself, in the same assistant // message. Closing it out locally would send a tool_result for an id the model // never asked us to run — an error of its own. func TestRestoreLeavesHostedToolCallsAlone(t *testing.T) { f := newPausableAPI(t, msg("end_turn", textBlock("ok"))) a := newInterruptibleAgent(t, f, t.TempDir(), make(chan struct{}, 1)) restored := []llm.Message{ {Role: llm.RoleUser, Content: []llm.ContentBlock{{Type: llm.BlockText, Text: "search"}}}, {Role: llm.RoleAssistant, Content: []llm.ContentBlock{ {Type: llm.BlockServerToolUse, ID: "srvtoolu_1", Name: "web_search", Input: []byte(`{}`)}, }}, } a.Restore(Session{Messages: restored}) if got := a.State().History(); len(got) != len(restored) { t.Errorf("history has %d messages, want the %d restored unchanged", len(got), len(restored)) } }