返回 DeepSeek-Reasonix
tool_started_barrier_test.go
根目录 / internal / agent / tool_started_barrier_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "sync"
8 "sync/atomic"
9 "testing"
10
11 "reasonix/internal/event"
12 "reasonix/internal/provider"
13 "reasonix/internal/tool"
14 )
15
16 // A checked sink models the durable acknowledgement, independently of the
17 // display-only Emit path. Only acknowledged records count as start evidence.
18 type startBarrierSink struct {
19 mu sync.Mutex
20 events []event.Event
21 failStart bool
22 }
23
24 func (s *startBarrierSink) Emit(e event.Event) {
25 s.mu.Lock()
26 defer s.mu.Unlock()
27 s.events = append(s.events, e)
28 }
29
30 func (s *startBarrierSink) EmitChecked(e event.Event) error {
31 if e.Kind == event.ToolStarted && s.failStart {
32 return errors.New("injected tool-start journal failure")
33 }
34 s.Emit(e)
35 return nil
36 }
37
38 func (s *startBarrierSink) starts(callID string) int {
39 s.mu.Lock()
40 defer s.mu.Unlock()
41 count := 0
42 for _, e := range s.events {
43 if e.Kind == event.ToolStarted && e.Tool.ID == callID {
44 count++
45 }
46 }
47 return count
48 }
49
50 func TestToolStartedNotEmittedForRejectedCalls(t *testing.T) {
51 for _, tc := range []struct {
52 name string
53 args string
54 opts Options
55 }{
56 {name: "permission denied", args: `{"value":"ok"}`, opts: Options{Gate: &stubGate{deny: map[string]bool{"barrier_probe": true}}}},
57 {name: "malformed arguments", args: `{"value":`},
58 {name: "schema violation", args: `{}`},
59 {name: "pre-execution hook denied", args: `{"value":"ok"}`, opts: Options{Hooks: &stubHooks{blockPre: map[string]bool{"barrier_probe": true}}}},
60 } {
61 t.Run(tc.name, func(t *testing.T) {
62 target := &recoveryArgumentTool{name: "barrier_probe", schema: json.RawMessage(`{"type":"object","properties":{"value":{"type":"string"}},"required":["value"]}`)}
63 reg := tool.NewRegistry()
64 reg.Add(target)
65 sink := &startBarrierSink{}
66 a := New(nil, reg, NewSession(""), tc.opts, sink)
67 batch := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{{ID: "rejected", Name: target.Name(), Arguments: tc.args}})
68 if len(target.inputs) != 0 {
69 t.Fatalf("rejected tool executed %d times", len(target.inputs))
70 }
71 if len(batch.outcomes) != 1 || batch.outcomes[0].errMsg == "" {
72 t.Fatalf("expected rejection outcome, got %+v", batch)
73 }
74 if got := sink.starts("rejected"); got != 0 {
75 t.Errorf("rejected call has %d durable start records; must remain unstarted", got)
76 }
77 })
78 }
79 }
80
81 func TestToolStartedPersistenceFailureDoesNotExecuteOrBecomeUnknown(t *testing.T) {
82 var executions int32
83 reg := tool.NewRegistry()
84 reg.Add(fakeTool{name: "barrier_probe", readOnly: true, calls: &executions})
85 sink := &startBarrierSink{failStart: true}
86 a := New(nil, reg, NewSession(""), Options{}, sink)
87 batch := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{{ID: "not-started", Name: "barrier_probe", Arguments: `{}`}})
88 if got := atomic.LoadInt32(&executions); got != 0 {
89 t.Fatalf("tool executed %d times despite failed start persistence", got)
90 }
91 if len(batch.outcomes) != 1 {
92 t.Fatalf("outcomes = %d, want 1", len(batch.outcomes))
93 }
94 if state := outcomeRunState(batch.outcomes[0]); state != provider.ToolRunNotStarted {
95 t.Errorf("failed start persistence state = %q, want %q; execution never began", state, provider.ToolRunNotStarted)
96 }
97 if got := sink.starts("not-started"); got != 0 {
98 t.Errorf("failed journal write created %d acknowledged records", got)
99 }
100 }
101
102 type startObservingTool struct {
103 sink *startBarrierSink
104 seen atomic.Int32
105 }
106
107 func (*startObservingTool) Name() string { return "barrier_probe" }
108 func (*startObservingTool) Description() string { return "observe durable start at dispatch" }
109 func (*startObservingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
110 func (*startObservingTool) ReadOnly() bool { return true }
111 func (t *startObservingTool) Execute(context.Context, json.RawMessage) (string, error) {
112 t.seen.Store(int32(t.sink.starts("executed")))
113 return "done", nil
114 }
115
116 func TestToolStartedAcknowledgedBeforeConcreteExecution(t *testing.T) {
117 sink := &startBarrierSink{}
118 target := &startObservingTool{sink: sink}
119 reg := tool.NewRegistry()
120 reg.Add(target)
121 a := New(nil, reg, NewSession(""), Options{}, sink)
122 batch := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{{ID: "executed", Name: target.Name(), Arguments: `{}`}})
123 if batch.err != nil || len(batch.outcomes) != 1 || batch.outcomes[0].errMsg != "" {
124 t.Fatalf("execution failed: %+v", batch)
125 }
126 if got := target.seen.Load(); got != 1 {
127 t.Errorf("tool observed %d acknowledged starts at execution, want exactly 1", got)
128 }
129 if got := sink.starts("executed"); got != 1 {
130 t.Errorf("call has %d starts after completion, want exactly 1", got)
131 }
132 }
133
133 lines GO