返回 DeepSeek-Reasonix
wire_test.go
根目录 / internal / eventwire / wire_test.go
1 package eventwire
2
3 import (
4 "encoding/json"
5 "errors"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "testing"
11
12 "reasonix/internal/event"
13 "reasonix/internal/provider"
14 )
15
16 func TestToWireRetryingJSON(t *testing.T) {
17 w := ToWire(event.Event{Kind: event.Retrying, RetryAttempt: 3, RetryMax: 10, RetryScope: event.RetryScopeStream})
18 b, err := json.Marshal(w)
19 if err != nil {
20 t.Fatalf("marshal: %v", err)
21 }
22 s := string(b)
23 for _, want := range []string{`"kind":"retrying"`, `"retryAttempt":3`, `"retryMax":10`, `"retryScope":"stream"`} {
24 if !strings.Contains(s, want) {
25 t.Fatalf("retrying JSON = %s, want it to contain %s", s, want)
26 }
27 }
28 }
29
30 func TestToWireStreamAttemptJSON(t *testing.T) {
31 w := ToWire(event.Event{
32 Kind: event.StreamAttempt,
33 StreamAttempt: event.StreamAttemptInfo{
34 ID: "sa-1", Action: event.StreamAttemptDiscard, Attempt: 2, Max: 6, Reason: "connection_reset",
35 },
36 })
37 b, err := json.Marshal(w)
38 if err != nil {
39 t.Fatalf("marshal: %v", err)
40 }
41 s := string(b)
42 for _, want := range []string{`"kind":"stream_attempt"`, `"id":"sa-1"`, `"action":"discard"`, `"attempt":2`, `"max":6`, `"reason":"connection_reset"`} {
43 if !strings.Contains(s, want) {
44 t.Fatalf("stream_attempt JSON = %s, want it to contain %s", s, want)
45 }
46 }
47 }
48
49 func TestToWireNoticeCarriesCode(t *testing.T) {
50 w := ToWire(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeFinalReadiness, Text: "readiness copy"})
51 b, err := json.Marshal(w)
52 if err != nil {
53 t.Fatalf("marshal: %v", err)
54 }
55 if !strings.Contains(string(b), `"code":"final_readiness"`) {
56 t.Fatalf("notice JSON = %s, want a stable code field", b)
57 }
58
59 w = ToWire(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "codeless notice"})
60 if b, err = json.Marshal(w); err != nil {
61 t.Fatalf("marshal: %v", err)
62 }
63 if strings.Contains(string(b), `"code"`) {
64 t.Fatalf("codeless notice JSON = %s, must omit the code field", b)
65 }
66
67 w = ToWire(event.Event{Kind: event.Text, Code: "stray"})
68 if b, err = json.Marshal(w); err != nil {
69 t.Fatalf("marshal: %v", err)
70 }
71 if strings.Contains(string(b), `"code"`) {
72 t.Fatalf("non-notice JSON = %s, must not carry a code", b)
73 }
74 }
75
76 func TestToWireNoticeCarriesDecisionReceipt(t *testing.T) {
77 w := ToWire(event.Event{
78 Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeDecisionReceipt,
79 Text: "Decision recorded: allow_once",
80 DecisionReceipt: &provider.DecisionReceipt{
81 ID: "approval-1", Kind: "tool", Tool: "write_file", Subject: "src/app.go", Outcome: "allow_once",
82 },
83 })
84 if w.DecisionReceipt == nil || w.DecisionReceipt.ID != "approval-1" || w.DecisionReceipt.Outcome != "allow_once" {
85 t.Fatalf("wire receipt = %+v", w.DecisionReceipt)
86 }
87 b, err := json.Marshal(w)
88 if err != nil {
89 t.Fatalf("marshal: %v", err)
90 }
91 for _, want := range []string{`"code":"decision_receipt"`, `"decisionReceipt"`, `"outcome":"allow_once"`} {
92 if !strings.Contains(string(b), want) {
93 t.Fatalf("receipt JSON = %s, want %s", b, want)
94 }
95 }
96 }
97
98 func TestKindNamesComplete(t *testing.T) {
99 for k := event.Kind(0); k < event.KindCount; k++ {
100 if ToWire(event.Event{Kind: k}).Kind == "" {
101 t.Fatalf("kind %d has no wire name", k)
102 }
103 }
104 }
105
106 func TestDesktopWireEventKindTypeCoversSharedKinds(t *testing.T) {
107 ts := readDesktopTypes(t)
108 for k := event.Kind(0); k < event.KindCount; k++ {
109 kind := ToWire(event.Event{Kind: k}).Kind
110 if !strings.Contains(ts, `"`+kind+`"`) {
111 t.Fatalf("desktop WireEvent EventKind is missing %q", kind)
112 }
113 }
114 }
115
116 func TestDesktopWireEventTypeCoversSharedPayloadFields(t *testing.T) {
117 ts := readDesktopTypes(t)
118 for _, want := range []string{
119 "detail?: string;",
120 `outcome?: "final_readiness" | "recovery_paused";`,
121 "retryAttempt?: number;",
122 "retryMax?: number;",
123 "retryScope?:",
124 "streamAttempt?: WireStreamAttempt;",
125 "export interface WireStreamAttempt",
126 "attemptId?: string;",
127 "contextPromptTokens?: number;",
128 "contextCompletionTokens?: number;",
129 "memoryCitations?: MemoryCitation[];",
130 "export interface MemoryCitation",
131 "resolvedName?: string;",
132 "capabilityId?: string;",
133 "cacheDiagnostics?: WireCacheDiagnostics;",
134 "export interface WireCacheDiagnostics",
135 "prefixHash: string;",
136 "prefixChanged: boolean;",
137 "prefixChangeReasons?: string[];",
138 "toolSchemaTokens: number;",
139 } {
140 if !strings.Contains(ts, want) {
141 t.Fatalf("desktop WireEvent types are missing %q", want)
142 }
143 }
144 }
145
146 func TestToWireNoticeDetail(t *testing.T) {
147 w := ToWire(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "short", Detail: "diagnostics"})
148 if w.Kind != "notice" || w.Level != "warn" || w.Text != "short" || w.Detail != "diagnostics" {
149 t.Fatalf("wire notice = %+v", w)
150 }
151 b, err := json.Marshal(w)
152 if err != nil {
153 t.Fatalf("marshal: %v", err)
154 }
155 for _, want := range []string{`"kind":"notice"`, `"text":"short"`, `"detail":"diagnostics"`, `"level":"warn"`} {
156 if !strings.Contains(string(b), want) {
157 t.Fatalf("notice JSON = %s, want it to contain %s", string(b), want)
158 }
159 }
160 }
161
162 func TestToWireToolCarriesResolvedCapabilityMetadata(t *testing.T) {
163 w := ToWire(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
164 ID: "c1", Name: "use_capability",
165 Args: `{"action":"call","capability_id":"mcp-tool:db/write"}`,
166 ResolvedName: "mcp__db__write", CapabilityID: "mcp-tool:db/write",
167 ReadOnly: false, Refreshed: true,
168 }})
169 b, err := json.Marshal(w)
170 if err != nil {
171 t.Fatalf("marshal: %v", err)
172 }
173 for _, want := range []string{
174 `"name":"use_capability"`, `"resolvedName":"mcp__db__write"`,
175 `"capabilityId":"mcp-tool:db/write"`, `"readOnly":false`, `"refreshed":true`,
176 } {
177 if !strings.Contains(string(b), want) {
178 t.Fatalf("tool JSON = %s, want %s", b, want)
179 }
180 }
181 }
182
183 func TestToWireTurnOutcomeIsOptionalAndMachineReadable(t *testing.T) {
184 readiness := ToWire(event.Event{
185 Kind: event.TurnDone,
186 Err: errors.New("final-answer readiness failed 3 times: missing verification"),
187 Outcome: event.TurnOutcomeFinalReadiness,
188 Readiness: &event.FinalReadiness{Attempts: 3, Missing: []string{"verification", "review"}},
189 })
190 if readiness.Outcome != event.TurnOutcomeFinalReadiness || readiness.Err == "" || readiness.Readiness == nil || readiness.Readiness.Attempts != 3 {
191 t.Fatalf("readiness wire event = %+v", readiness)
192 }
193 b, err := json.Marshal(readiness)
194 if err != nil {
195 t.Fatalf("marshal readiness: %v", err)
196 }
197 if !strings.Contains(string(b), `"outcome":"final_readiness"`) || !strings.Contains(string(b), `"missing":["verification","review"]`) {
198 t.Fatalf("readiness JSON = %s, want structured outcome", b)
199 }
200
201 ordinary, err := json.Marshal(ToWire(event.Event{Kind: event.TurnDone, Err: errors.New("provider failed")}))
202 if err != nil {
203 t.Fatalf("marshal ordinary error: %v", err)
204 }
205 if strings.Contains(string(ordinary), `"outcome"`) {
206 t.Fatalf("ordinary error JSON must omit outcome: %s", ordinary)
207 }
208 }
209
210 func TestToWireMessageMemoryCitations(t *testing.T) {
211 w := ToWire(event.Event{
212 Kind: event.Message,
213 Text: "done",
214 MemoryCitations: []provider.MemoryCitation{{
215 ID: "mem-1",
216 Source: "MEMORY.md",
217 LineStart: 116,
218 LineEnd: 123,
219 Note: "reasonix workflow",
220 Kind: "memory_reference",
221 }},
222 })
223 if len(w.MemoryCitations) != 1 {
224 t.Fatalf("memory citations = %+v, want one citation", w.MemoryCitations)
225 }
226 got := w.MemoryCitations[0]
227 if got.Source != "MEMORY.md" || got.LineStart != 116 || got.LineEnd != 123 || got.Note != "reasonix workflow" {
228 t.Fatalf("citation = %+v, want source/line/note preserved", got)
229 }
230 b, err := json.Marshal(w)
231 if err != nil {
232 t.Fatalf("marshal: %v", err)
233 }
234 if !strings.Contains(string(b), `"memoryCitations":[`) {
235 t.Fatalf("wire JSON missing memoryCitations: %s", string(b))
236 }
237 }
238
239 func readDesktopTypes(t *testing.T) string {
240 t.Helper()
241 _, file, _, ok := runtime.Caller(0)
242 if !ok {
243 t.Fatal("runtime caller unavailable")
244 }
245 path := filepath.Join(filepath.Dir(file), "..", "..", "desktop", "frontend", "src", "lib", "types.ts")
246 b, err := os.ReadFile(path)
247 if err != nil {
248 t.Fatalf("read desktop types: %v", err)
249 }
250 return string(b)
251 }
252
253 func TestToWireToolPayloadJSON(t *testing.T) {
254 w := ToWire(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
255 ID: "call-1", Name: "task", Args: `{"prompt":"x"}`, Output: "ignored",
256 Err: "blocked", ReadOnly: true, Truncated: true, DurationMs: 522,
257 Partial: true, Refreshed: true, ParentID: "parent-1",
258 FileDiff: event.FileDiff{Diff: "@@ -1 +1 @@\n-old\n+new\n", Added: 1, Removed: 1},
259 Profile: &event.Profile{Model: "deepseek-pro", Effort: "max"},
260 }})
261 b, err := json.Marshal(w)
262 if err != nil {
263 t.Fatalf("marshal: %v", err)
264 }
265 s := string(b)
266 for _, want := range []string{
267 `"kind":"tool_dispatch"`, `"id":"call-1"`, `"name":"task"`,
268 `"args":"{\"prompt\":\"x\"}"`, `"output":"ignored"`, `"err":"blocked"`,
269 `"readOnly":true`, `"truncated":true`, `"durationMs":522`, `"partial":true`, `"refreshed":true`,
270 `"parentId":"parent-1"`, `"diff":"@@ -1 +1 @@\n-old\n+new\n"`,
271 `"added":1`, `"removed":1`, `"profile":{"model":"deepseek-pro","effort":"max"}`,
272 } {
273 if !strings.Contains(s, want) {
274 t.Fatalf("tool JSON = %s, want it to contain %s", s, want)
275 }
276 }
277 }
278
279 func TestToWireUsagePayloadJSON(t *testing.T) {
280 w := ToWire(event.Event{
281 Kind: event.Usage,
282 Usage: &provider.Usage{
283 PromptTokens: 1000, CompletionTokens: 200, TotalTokens: 1200,
284 CacheHitTokens: 900, CacheMissTokens: 100, ReasoningTokens: 33, Estimated: true,
285 },
286 Pricing: &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2},
287 UsageSource: event.UsageSourceTitle,
288 CacheDiagnostics: &event.CacheDiagnostics{
289 PrefixHash: "p", PrefixChanged: true, PrefixChangeReasons: []string{"log_rewrite"},
290 SystemHash: "s", ToolsHash: "t", LogRewriteVersion: 1, ToolSchemaTokens: 42,
291 CacheMissTokens: 100, CacheHitTokens: 900,
292 },
293 SessionHit: 8000, SessionMiss: 2000,
294 })
295 b, err := json.Marshal(w)
296 if err != nil {
297 t.Fatalf("marshal: %v", err)
298 }
299 s := string(b)
300 for _, want := range []string{
301 `"kind":"usage"`, `"promptTokens":1000`, `"completionTokens":200`, `"totalTokens":1200`,
302 `"cacheHitTokens":900`, `"cacheMissTokens":100`, `"reasoningTokens":33`,
303 `"estimated":true`,
304 `"source":"title"`, `"sessionCacheHitTokens":8000`, `"sessionCacheMissTokens":2000`,
305 `"currency":"¥"`, `"costUsd":`, `"cacheDiagnostics":`, `"prefixHash":"p"`,
306 `"prefixChanged":true`, `"prefixChangeReasons":["log_rewrite"]`, `"toolSchemaTokens":42`,
307 } {
308 if !strings.Contains(s, want) {
309 t.Fatalf("usage JSON = %s, want it to contain %s", s, want)
310 }
311 }
312 }
313
314 func TestToWireInteractionAndLifecyclePayloads(t *testing.T) {
315 tests := []struct {
316 name string
317 in event.Event
318 want []string
319 }{
320 {
321 name: "approval",
322 in: event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash", Subject: "rm"}},
323 want: []string{`"kind":"approval_request"`, `"approval":{"id":"a1","tool":"bash","subject":"rm"}`},
324 },
325 {
326 name: "fresh approval",
327 in: event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a2", Tool: "mcp__srv__wipe", Subject: "srv/wipe", Fresh: true}},
328 want: []string{`"kind":"approval_request"`, `"tool":"mcp__srv__wipe"`, `"fresh":true`},
329 },
330 {
331 name: "recovery task grant",
332 in: event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
333 ID: "r1", Tool: "bash", Subject: "git push origin feature", Fresh: true, Kind: "recovery",
334 Recovery: &event.RecoveryApproval{
335 NextAction: "git push origin feature", CanGrantTask: true,
336 TaskGrantScope: "git push origin → feature",
337 },
338 }},
339 want: []string{
340 `"kind":"recovery"`, `"next_action":"git push origin feature"`, `"can_grant_task":true`,
341 `"task_grant_scope":"git push origin → feature"`,
342 },
343 },
344 {
345 name: "recovery plan transition",
346 in: event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
347 ID: "r-plan", Tool: "todo_write", Subject: "Update the active execution plan", Fresh: true, Kind: "recovery",
348 Recovery: &event.RecoveryApproval{
349 ChangeKind: "scope", PlanBefore: "1. Keep API", PlanAfter: "1. Replace API",
350 },
351 }},
352 want: []string{
353 `"kind":"recovery"`, `"change_kind":"scope"`,
354 `"plan_before":"1. Keep API"`, `"plan_after":"1. Replace API"`,
355 },
356 },
357 {
358 name: "ask",
359 in: event.Event{Kind: event.AskRequest, Ask: event.Ask{
360 ID: "ask-1",
361 Questions: []event.AskQuestion{{
362 ID: "q1", Header: "Pick", Prompt: "Choose", Multi: true,
363 Options: []event.AskOption{{Label: "A", Description: "Alpha"}, {Label: "B"}},
364 }},
365 }},
366 want: []string{`"kind":"ask_request"`, `"ask":{"id":"ask-1"`, `"header":"Pick"`, `"description":"Alpha"`, `"multi":true`},
367 },
368 {
369 name: "compaction",
370 in: event.Event{Kind: event.CompactionDone, Compaction: event.Compaction{
371 Trigger: "manual", Messages: 7, Summary: "brief", Archive: "/tmp/archive.jsonl",
372 }},
373 want: []string{`"kind":"compaction_done"`, `"trigger":"manual"`, `"messages":7`, `"summary":"brief"`, `"archive":"/tmp/archive.jsonl"`},
374 },
375 {
376 name: "turn done error",
377 in: event.Event{Kind: event.TurnDone, Err: errors.New("boom")},
378 want: []string{`"kind":"turn_done"`, `"err":"boom"`},
379 },
380 {
381 name: "steer",
382 in: event.Event{Kind: event.Steer, Text: "mid-turn guidance"},
383 want: []string{`"kind":"steer"`, `"text":"mid-turn guidance"`},
384 },
385 }
386 for _, tt := range tests {
387 t.Run(tt.name, func(t *testing.T) {
388 b, err := json.Marshal(ToWire(tt.in))
389 if err != nil {
390 t.Fatalf("marshal: %v", err)
391 }
392 s := string(b)
393 for _, want := range tt.want {
394 if !strings.Contains(s, want) {
395 t.Fatalf("%s JSON = %s, want it to contain %s", tt.name, s, want)
396 }
397 }
398 })
399 }
400 }
401
401 lines GO