| 1 | package agent |
| 2 | |
| 3 | // Core agent-loop contract tests (agent-core simplification baseline). Each |
| 4 | // test pins one behavior the simplified loop promises; suites covering the |
| 5 | // remaining contract items are listed in docs/AGENT_CORE_SIMPLIFICATION.md. |
| 6 | |
| 7 | import ( |
| 8 | "context" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/internal/agent/testutil" |
| 12 | "reasonix/internal/event" |
| 13 | "reasonix/internal/provider" |
| 14 | "reasonix/internal/tool" |
| 15 | ) |
| 16 | |
| 17 | // TestContractCleanFinalMakesOneModelRequest pins the executor-only happy |
| 18 | // path: one user turn, one provider request, no host follow-ups. |
| 19 | func TestContractCleanFinalMakesOneModelRequest(t *testing.T) { |
| 20 | prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{ |
| 21 | {{Type: provider.ChunkText, Text: "the answer"}, {Type: provider.ChunkDone}}, |
| 22 | }} |
| 23 | a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, event.Discard) |
| 24 | |
| 25 | if err := a.Run(context.Background(), "question"); err != nil { |
| 26 | t.Fatalf("Run: %v", err) |
| 27 | } |
| 28 | if len(prov.requests) != 1 { |
| 29 | t.Fatalf("provider requests = %d, want exactly one for a clean final", len(prov.requests)) |
| 30 | } |
| 31 | if got := lastAssistantContent(a.sess.conversation); got != "the answer" { |
| 32 | t.Fatalf("last assistant content = %q, want the final answer", got) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | // TestContractToolCallAdvancesToNextStep pins the tool-loop shape: a tool-call |
| 37 | // round executes and the loop continues to a second request for the final. |
| 38 | func TestContractToolCallAdvancesToNextStep(t *testing.T) { |
| 39 | mp := testutil.NewMock("m", |
| 40 | testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "echo", Arguments: `{"text":"hi"}`}}}, |
| 41 | testutil.Turn{Text: "all set"}, |
| 42 | ) |
| 43 | a := New(mp, echoRegistry(), NewSession(""), Options{}, event.Discard) |
| 44 | |
| 45 | if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil { |
| 46 | t.Fatalf("Run: %v", err) |
| 47 | } |
| 48 | if got := mp.CallCount(); got != 2 { |
| 49 | t.Fatalf("provider calls = %d, want tool round + final", got) |
| 50 | } |
| 51 | if got := lastToolResult(a.Session(), "echo"); got == "" { |
| 52 | t.Fatal("tool result missing from session; tool round did not execute") |
| 53 | } |
| 54 | if got := lastAssistantContent(a.sess.conversation); got != "all set" { |
| 55 | t.Fatalf("last assistant content = %q, want the post-tool final", got) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // TestContractExplicitRetryPreservesThinking verifies that a failed turn leaves |
| 60 | // no degraded mode behind and the next user submission keeps normal reasoning. |
| 61 | func TestContractExplicitRetryPreservesThinking(t *testing.T) { |
| 62 | prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{ |
| 63 | {{Type: provider.ChunkDone}}, |
| 64 | {{Type: provider.ChunkReasoning, Text: "need to think"}, {Type: provider.ChunkText, Text: "the answer"}, {Type: provider.ChunkDone}}, |
| 65 | }} |
| 66 | a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, event.Discard) |
| 67 | if err := a.Run(t.Context(), "question"); err == nil { |
| 68 | t.Fatal("empty response accepted") |
| 69 | } |
| 70 | if len(prov.requests) != 1 { |
| 71 | t.Fatal("failed request retried automatically") |
| 72 | } |
| 73 | if err := a.Run(t.Context(), "try again"); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | if len(prov.requests) != 2 { |
| 77 | t.Fatal("manual retry did not make exactly one request") |
| 78 | } |
| 79 | msgs := a.Session().Snapshot() |
| 80 | last := msgs[len(msgs)-1] |
| 81 | if last.Role != provider.RoleAssistant || last.Content != "the answer" || last.ReasoningContent != "need to think" { |
| 82 | t.Fatalf("lost normal response: %+v", last) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // TestContractCleanFinalAddsNoSyntheticContinuation pins that a direct Run |
| 87 | // ends at the first clean final: no host-generated user message may appear in |
| 88 | // the session, whatever the model text promises. |
| 89 | func TestContractCleanFinalAddsNoSyntheticContinuation(t *testing.T) { |
| 90 | prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{ |
| 91 | {{Type: provider.ChunkText, Text: "I will handle it next round."}, {Type: provider.ChunkDone}}, |
| 92 | }} |
| 93 | a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, event.Discard) |
| 94 | |
| 95 | if err := a.Run(context.Background(), "question"); err != nil { |
| 96 | t.Fatalf("Run: %v", err) |
| 97 | } |
| 98 | if len(prov.requests) != 1 { |
| 99 | t.Fatalf("provider requests = %d, want one; a promised-later answer must not buy a continuation", len(prov.requests)) |
| 100 | } |
| 101 | for _, prefix := range []string{StandardTodoContinuationPrefix, "visible answer", executorHandoffMarker} { |
| 102 | if sessionHasUserMessageContaining(a.sess.conversation, prefix) { |
| 103 | t.Fatalf("session contains synthetic continuation prompt %q", prefix) |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 |