返回 DeepSeek-Reasonix
agent_contract_test.go
根目录 / internal / agent / agent_contract_test.go
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
107 lines GO