返回 DeepSeek-Reasonix
settlement_publication_test.go
根目录 / internal / agent / settlement_publication_test.go
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "sync"
7 "testing"
8
9 "reasonix/internal/agent/testutil"
10 "reasonix/internal/event"
11 "reasonix/internal/provider"
12 )
13
14 // This recorder models the acceptance boundary, independently of the display
15 // sink. Tests observe it at publication time rather than inspecting the fully
16 // populated conversation after Run has returned.
17 type settlementRecorder struct {
18 mu sync.Mutex
19 accepted map[string]provider.Message
20 reject error
21 }
22
23 func (*settlementRecorder) CheckpointSession(ctx context.Context, _ SessionCheckpointBoundary) error {
24 return ctx.Err()
25 }
26
27 func (r *settlementRecorder) RecordSessionMessages(_ context.Context, _ string, messages []provider.Message) error {
28 r.mu.Lock()
29 defer r.mu.Unlock()
30 for _, message := range messages {
31 if message.Role == provider.RoleAssistant && r.reject != nil {
32 return r.reject
33 }
34 }
35 for _, message := range messages {
36 r.accepted[message.ID] = message
37 }
38 return nil
39 }
40
41 func (r *settlementRecorder) message(id string) (provider.Message, bool) {
42 r.mu.Lock()
43 defer r.mu.Unlock()
44 message, exists := r.accepted[id]
45 return message, exists
46 }
47
48 func TestAssistantSettlementPublicationFollowsBusinessAcceptance(t *testing.T) {
49 for _, tc := range []struct {
50 name string
51 turns []testutil.Turn
52 }{
53 {name: "answer", turns: []testutil.Turn{{Text: "final answer", Reasoning: "final reasoning"}}},
54 {name: "reasoning-only", turns: []testutil.Turn{{Reasoning: "reasoning-only result"}}},
55 {name: "tool-bearing", turns: []testutil.Turn{
56 {Text: "checking", Reasoning: "tool reasoning", ToolCalls: []provider.ToolCall{{ID: "echo-1", Name: "echo", Arguments: `{"text":"one"}`}}},
57 {Text: "done"},
58 }},
59 {name: "tool-only", turns: []testutil.Turn{
60 {ToolCalls: []provider.ToolCall{{ID: "echo-1", Name: "echo", Arguments: `{"text":"one"}`}}},
61 {Text: "done"},
62 }},
63 } {
64 t.Run(tc.name, func(t *testing.T) {
65 recorder := &settlementRecorder{accepted: make(map[string]provider.Message)}
66 messages, commits := make(map[string]int), make(map[string]int)
67 sink := event.FuncSink(func(e event.Event) {
68 if e.Kind != event.Message && !(e.Kind == event.StreamAttempt && e.StreamAttempt.Action == event.StreamAttemptCommit) {
69 return
70 }
71 accepted, exists := recorder.message(e.MessageID)
72 if !exists || accepted.Role != provider.RoleAssistant {
73 t.Errorf("settlement kind=%v attempt=%q published before assistant business acceptance", e.Kind, e.AttemptID)
74 return
75 }
76 if e.AttemptID != accepted.ID {
77 t.Errorf("settlement identity changed: attempt=%q accepted=%q", e.AttemptID, accepted.ID)
78 }
79 if e.Kind == event.Message {
80 messages[accepted.ID]++
81 if e.Text != accepted.Content || e.Reasoning != accepted.ReasoningContent {
82 t.Errorf("published message differs from accepted body: text=%q reasoning=%q", e.Text, e.Reasoning)
83 }
84 } else {
85 commits[accepted.ID]++
86 }
87 })
88 p := testutil.NewMock("test", tc.turns...)
89 a := New(p, echoRegistry(), NewSession("system"), Options{SessionCheckpointer: recorder}, sink)
90 if err := a.Run(withNoClosedLoop(t.Context()), "question"); err != nil {
91 t.Fatal(err)
92 }
93 assistantCount := 0
94 for _, message := range a.Session().Snapshot() {
95 if message.Role != provider.RoleAssistant {
96 continue
97 }
98 assistantCount++
99 if commits[message.ID] != 1 {
100 t.Errorf("assistant %q has %d commit publications, want one", message.ID, commits[message.ID])
101 }
102 wantMessages := 0
103 if message.Content != "" || message.ReasoningContent != "" {
104 wantMessages = 1
105 }
106 if messages[message.ID] != wantMessages {
107 t.Errorf("assistant %q has %d complete-message publications, want %d", message.ID, messages[message.ID], wantMessages)
108 }
109 }
110 if assistantCount != len(tc.turns) {
111 t.Errorf("accepted assistants=%d, want %d", assistantCount, len(tc.turns))
112 }
113 })
114 }
115 }
116
117 func TestAssistantAcceptanceFailureDoesNotPublishSuccessfulSettlement(t *testing.T) {
118 rejected := errors.New("assistant commit unavailable")
119 recorder := &settlementRecorder{accepted: make(map[string]provider.Message), reject: rejected}
120 streamed := false
121 sink := event.FuncSink(func(e event.Event) {
122 if e.Kind == event.Text {
123 streamed = true
124 }
125 if e.Kind == event.Message || e.Kind == event.StreamAttempt && e.StreamAttempt.Action == event.StreamAttemptCommit {
126 t.Errorf("rejected assistant published successful settlement kind=%v", e.Kind)
127 }
128 })
129 a := New(testutil.NewMock("test", testutil.Turn{Text: "visible partial answer"}), echoRegistry(), NewSession("system"), Options{SessionCheckpointer: recorder}, sink)
130 if err := a.Run(withNoClosedLoop(t.Context()), "question"); !errors.Is(err, rejected) {
131 t.Fatalf("Run error=%v, want acceptance failure", err)
132 }
133 if !streamed {
134 t.Fatal("fixture did not emit the partial answer before commit failed")
135 }
136 }
137
137 lines GO