返回 DeepSeek-Reasonix
user_input_test.go
根目录 / internal / agent / user_input_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8
9 "reasonix/internal/attachment"
10 "reasonix/internal/event"
11 "reasonix/internal/provider"
12 "reasonix/internal/tool"
13 )
14
15 type userInputCaptureProvider struct {
16 request provider.Request
17 }
18
19 func (p *userInputCaptureProvider) Name() string { return "capture" }
20
21 func (p *userInputCaptureProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
22 p.request = req
23 ch := make(chan provider.Chunk, 1)
24 ch <- provider.Chunk{Type: provider.ChunkText, Text: "done"}
25 close(ch)
26 return ch, nil
27 }
28
29 func TestRunPersistsRawUserInputSeparatelyFromProviderContext(t *testing.T) {
30 prov := &userInputCaptureProvider{}
31 sess := NewSession("system")
32 a := New(prov, tool.NewRegistry(), sess, Options{}, event.Discard)
33
34 const raw = "fix the bug"
35 const composed = "<capability-route version=\"1\">\nuse review\n</capability-route>\n\nfix the bug"
36 ctx := withNoClosedLoop(WithRawUserInput(context.Background(), raw))
37 if err := a.Run(ctx, composed); err != nil {
38 t.Fatalf("Run: %v", err)
39 }
40
41 stored := sess.Snapshot()
42 if len(stored) < 2 {
43 t.Fatalf("stored messages = %d, want system and user", len(stored))
44 }
45 if got := stored[1].Content; !strings.HasPrefix(got, composed) || strings.Contains(got, "<execution-policy") {
46 t.Fatalf("stored provider content = %q, want composed %q without execution-policy", got, composed)
47 }
48 if got := stored[1].RawContent; got != raw {
49 t.Fatalf("stored raw content = %q, want raw %q", got, raw)
50 }
51 if stored[1].Origin != provider.MessageOriginUser {
52 t.Fatalf("stored origin = %q, want user", stored[1].Origin)
53 }
54 if stored[1].ProviderContent != "" {
55 t.Fatalf("stored transitional provider content was not cleared: %+v", stored[1])
56 }
57 if len(prov.request.Messages) < 2 || !strings.HasPrefix(prov.request.Messages[1].Content, composed) {
58 t.Fatalf("provider request did not receive composed context: %+v", prov.request.Messages)
59 }
60 if prov.request.Messages[1].RawContent != "" || prov.request.Messages[1].ProviderContent != "" || prov.request.Messages[1].Origin != "" {
61 t.Fatalf("provider request leaked display metadata: %+v", prov.request.Messages[1])
62 }
63
64 encoded, err := json.Marshal(stored[1])
65 if err != nil {
66 t.Fatalf("marshal stored user turn: %v", err)
67 }
68 var legacy struct {
69 Content string `json:"content"`
70 }
71 if err := json.Unmarshal(encoded, &legacy); err != nil {
72 t.Fatalf("decode with previous-release shape: %v", err)
73 }
74 if !strings.HasPrefix(legacy.Content, composed) || strings.Contains(legacy.Content, "<execution-policy") {
75 t.Fatalf("stored content = %q, want composed prefix without execution-policy", legacy.Content)
76 }
77
78 if receipt := a.CompletionReceipt(); receipt != nil && len(receipt.Gaps) > 0 {
79 if strings.Contains(receipt.Gaps[0].Detail, "capability-route") {
80 t.Fatalf("completion receipt leaked transient provider context: %+v", receipt.Gaps)
81 }
82 }
83 }
84
85 func TestTransientCapabilityRouteCannotTurnConversationIntoDeliveryReceipt(t *testing.T) {
86 prov := &userInputCaptureProvider{}
87 a := New(prov, tool.NewRegistry(), NewSession("system"), Options{}, event.Discard)
88
89 const raw = "请解释这个项目目前的进度"
90 const composed = `<capability-route version="1">
91 Relevant capabilities for this turn:
92 - skill:minimax-docx prefer: the skill trigger matches the user request
93 Policy: prefer means use the skill for the required change
94 </capability-route>
95
96 ` + raw
97 if err := a.Run(WithRawUserInput(context.Background(), raw), composed); err != nil {
98 t.Fatalf("Run: %v", err)
99 }
100
101 if a.CompletionReceipt() != nil {
102 t.Fatalf("an advisory turn received a delivery receipt from transient routing: %+v", a.CompletionReceipt())
103 }
104 if len(prov.request.Messages) < 2 ||
105 !strings.Contains(prov.request.Messages[1].Content, `<capability-route version="1">`) ||
106 !strings.Contains(prov.request.Messages[1].Content, raw) {
107 t.Fatalf("provider lost the capability route: %+v", prov.request.Messages)
108 }
109 if got := a.turn.turnInput; got != raw {
110 t.Fatalf("contract input = %q, want authenticated raw input %q", got, raw)
111 }
112 if result := a.ReadinessResult(); !result.Ready || len(result.Missing) != 0 {
113 t.Fatalf("transient route created requirements: %+v", result)
114 }
115 }
116
117 func TestCompletionContractUsesGoalScopeTaskText(t *testing.T) {
118 prov := &userInputCaptureProvider{}
119 a := New(prov, tool.NewRegistry(), NewSession("system"), Options{}, event.Discard)
120 ctx := withNoClosedLoop(WithRawUserInput(context.Background(), "Continue working."))
121 ctx = WithDeliveryExecutionScope(ctx, DeliveryExecutionScope{ID: "goal-1", TaskText: "fix the parser"})
122
123 if err := a.Run(ctx, "<goal-context>continue</goal-context>"); err != nil {
124 t.Fatalf("Run: %v", err)
125 }
126 if got := a.turn.turnInput; got != "fix the parser" {
127 t.Fatalf("goal scope task text = %q", got)
128 }
129 }
130
131 func TestCompletionContractUsesPristineSubagentTaskText(t *testing.T) {
132 prov := &userInputCaptureProvider{}
133 a := New(prov, tool.NewRegistry(), NewSession("system"), Options{
134 ClassifierTaskText: "fix the parser",
135 }, event.Discard)
136 const wrapped = "<workspace-context>private host framing</workspace-context>\n\nfix the parser"
137
138 if err := a.Run(withNoClosedLoop(context.Background()), wrapped); err != nil {
139 t.Fatalf("Run: %v", err)
140 }
141 if got := a.turn.turnInput; got != "fix the parser" {
142 t.Fatalf("classifier task text = %q", got)
143 }
144 }
145
146 func TestSubagentImageCandidatesAreCopiedAndIsolated(t *testing.T) {
147 images := []string{"data:image/png;base64,AAAA"}
148 ctx := WithSubagentImageCandidates(context.Background(), images)
149 images[0] = "mutated"
150
151 got := SubagentImageCandidates(ctx)
152 if len(got) != 1 || got[0] != "data:image/png;base64,AAAA" {
153 t.Fatalf("candidates = %v, want an isolated copy of the original image", got)
154 }
155 got[0] = "mutated again"
156 if again := SubagentImageCandidates(ctx); again[0] != "data:image/png;base64,AAAA" {
157 t.Fatalf("candidate accessor exposed mutable context state: %v", again)
158 }
159 }
160
161 func TestSubagentImageInputsAreCopiedAndPreferredOverCandidates(t *testing.T) {
162 inputs := []attachment.ImageInput{{Kind: attachment.KindURL, URL: "https://example.invalid/a.png"}}
163 ctx := WithSubagentImageInputs(context.Background(), inputs)
164 inputs[0].URL = "mutated"
165 got := SubagentImageInputs(ctx)
166 if len(got) != 1 || got[0].URL != "https://example.invalid/a.png" {
167 t.Fatalf("inputs = %+v", got)
168 }
169 ctx = WithSubagentImageCandidates(ctx, []string{"data:image/png;base64,AAAA"})
170 wired := withSubagentTurnImages(ctx)
171 if len(userImages(wired)) != 0 {
172 t.Fatalf("ImageInputs path leaked Images = %v", userImages(wired))
173 }
174 if len(userImageInputs(wired)) != 1 || userImageInputs(wired)[0].URL != "https://example.invalid/a.png" {
175 t.Fatalf("wired ImageInputs = %+v", userImageInputs(wired))
176 }
177 }
178
178 lines GO