返回 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 recovery := &event.RecoveryStatus{Phase: "headers", NextAttemptAt: 1700000000000, WaitedMs: 14000, WaitBudgetMs: 600000, Waiting: true}
18 w := ToWire(event.Event{Kind: event.Retrying, RetryAttempt: 3, RetryMax: 10, RetryScope: event.RetryScopeStream, Recovery: recovery})
19 b, err := json.Marshal(w)
20 if err != nil {
21 t.Fatalf("marshal: %v", err)
22 }
23 s := string(b)
24 for _, want := range []string{`"kind":"retrying"`, `"retryAttempt":3`, `"retryMax":10`, `"retryScope":"stream"`, `"next_attempt_at":1700000000000`, `"waited_ms":14000`, `"wait_budget_ms":600000`, `"waiting":true`} {
25 if !strings.Contains(s, want) {
26 t.Fatalf("retrying JSON = %s, want it to contain %s", s, want)
27 }
28 }
29 }
30
31 func TestToWireStreamAttemptJSON(t *testing.T) {
32 w := ToWire(event.Event{
33 Kind: event.StreamAttempt,
34 StreamAttempt: event.StreamAttemptInfo{
35 ID: "sa-1", Action: event.StreamAttemptDiscard, Attempt: 2, Max: 6, Reason: "connection_reset",
36 },
37 })
38 b, err := json.Marshal(w)
39 if err != nil {
40 t.Fatalf("marshal: %v", err)
41 }
42 s := string(b)
43 for _, want := range []string{`"kind":"stream_attempt"`, `"id":"sa-1"`, `"action":"discard"`, `"attempt":2`, `"max":6`, `"reason":"connection_reset"`} {
44 if !strings.Contains(s, want) {
45 t.Fatalf("stream_attempt JSON = %s, want it to contain %s", s, want)
46 }
47 }
48 }
49
50 func TestToWireWorkspaceChangedKeepsBoundedEmptyArrays(t *testing.T) {
51 w := ToWire(event.Event{Kind: event.WorkspaceChanged, Workspace: &event.WorkspaceChangedPayload{
52 Revisions: event.WorkspaceRevision{Content: 4, Tree: 2, WorkingTree: 3, GitMeta: 1, Session: 7},
53 WatchState: event.WorkspaceWatchDegraded,
54 Source: "reconcile",
55 }})
56 if w.Workspace == nil || w.Workspace.Changes == nil {
57 t.Fatalf("workspace payload/changes must be non-nil: %+v", w.Workspace)
58 }
59 b, err := json.Marshal(w)
60 if err != nil {
61 t.Fatal(err)
62 }
63 for _, want := range []string{`"kind":"workspace_changed"`, `"changes":[]`, `"watchState":"degraded"`, `"session":7`} {
64 if !strings.Contains(string(b), want) {
65 t.Fatalf("workspace JSON = %s, missing %s", b, want)
66 }
67 }
68 }
69
70 func TestToWireCompletionSummaryCarriesTurnTimeAttention(t *testing.T) {
71 w := ToWire(event.Event{Kind: event.CompletionSummary, Completion: &event.CompletionSummaryInfo{
72 Verdict: "partial", ChecksSuppressed: 1, Floor: "delivery", Attention: true,
73 }})
74 b, err := json.Marshal(w)
75 if err != nil {
76 t.Fatal(err)
77 }
78 for _, want := range []string{`"kind":"completion_summary"`, `"floor":"delivery"`, `"attention":true`} {
79 if !strings.Contains(string(b), want) {
80 t.Fatalf("completion JSON = %s, missing %s", b, want)
81 }
82 }
83 }
84
85 func TestToWireContextMaintenanceJSON(t *testing.T) {
86 w := ToWire(event.Event{Kind: event.ContextMaintenanceEvent, Maintenance: &event.ContextMaintenance{
87 Status: "applied", Action: "prune", SavedTokens: 4096, ProjectionVersion: 3, CacheBreak: true,
88 }})
89 b, err := json.Marshal(w)
90 if err != nil {
91 t.Fatalf("marshal: %v", err)
92 }
93 for _, want := range []string{`"kind":"context_maintenance"`, `"action":"prune"`, `"savedTokens":4096`, `"projectionVersion":3`, `"cacheBreak":true`} {
94 if !strings.Contains(string(b), want) {
95 t.Fatalf("context maintenance JSON = %s, want %s", b, want)
96 }
97 }
98 }
99
100 func TestToWireNoticeCarriesCode(t *testing.T) {
101 w := ToWire(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeFinalReadiness, Text: "readiness copy"})
102 b, err := json.Marshal(w)
103 if err != nil {
104 t.Fatalf("marshal: %v", err)
105 }
106 if !strings.Contains(string(b), `"code":"final_readiness"`) {
107 t.Fatalf("notice JSON = %s, want a stable code field", b)
108 }
109
110 w = ToWire(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "codeless notice"})
111 if b, err = json.Marshal(w); err != nil {
112 t.Fatalf("marshal: %v", err)
113 }
114 if strings.Contains(string(b), `"code"`) {
115 t.Fatalf("codeless notice JSON = %s, must omit the code field", b)
116 }
117
118 w = ToWire(event.Event{Kind: event.Text, Code: "stray"})
119 if b, err = json.Marshal(w); err != nil {
120 t.Fatalf("marshal: %v", err)
121 }
122 if strings.Contains(string(b), `"code"`) {
123 t.Fatalf("non-notice JSON = %s, must not carry a code", b)
124 }
125 }
126
127 func TestToWireWriteAccessApprovalKeepsNonNilArrays(t *testing.T) {
128 w := ToWire(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
129 ID: "a3", Tool: "bash", Subject: "install", Kind: event.ApprovalKindWriteAccess,
130 WriteAccess: event.NormalizeWriteAccessApproval(&event.WriteAccessApproval{}),
131 }})
132 b, err := json.Marshal(w)
133 if err != nil {
134 t.Fatal(err)
135 }
136 body := string(b)
137 if !strings.Contains(body, `"write_access"`) || !strings.Contains(body, `"directories":[]`) {
138 t.Fatalf("write_access arrays must be [] not null: %s", body)
139 }
140 }
141
142 func TestToWireNoticeCarriesDecisionReceipt(t *testing.T) {
143 w := ToWire(event.Event{
144 Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeDecisionReceipt,
145 Text: "Decision recorded: allow_once",
146 DecisionReceipt: &provider.DecisionReceipt{
147 ID: "approval-1", Kind: "tool", Tool: "write_file", Subject: "src/app.go", Outcome: "allow_once",
148 },
149 })
150 if w.DecisionReceipt == nil || w.DecisionReceipt.ID != "approval-1" || w.DecisionReceipt.Outcome != "allow_once" {
151 t.Fatalf("wire receipt = %+v", w.DecisionReceipt)
152 }
153 b, err := json.Marshal(w)
154 if err != nil {
155 t.Fatalf("marshal: %v", err)
156 }
157 for _, want := range []string{`"code":"decision_receipt"`, `"decisionReceipt"`, `"outcome":"allow_once"`} {
158 if !strings.Contains(string(b), want) {
159 t.Fatalf("receipt JSON = %s, want %s", b, want)
160 }
161 }
162 }
163
164 func TestKindNamesComplete(t *testing.T) {
165 for k := range event.KindCount {
166 if ToWire(event.Event{Kind: k}).Kind == "" {
167 t.Fatalf("kind %d has no wire name", k)
168 }
169 }
170 }
171
172 func TestDesktopWireEventKindTypeCoversSharedKinds(t *testing.T) {
173 ts := readDesktopTypes(t)
174 for k := range event.KindCount {
175 kind := ToWire(event.Event{Kind: k}).Kind
176 if !strings.Contains(ts, `"`+kind+`"`) {
177 t.Fatalf("desktop WireEvent EventKind is missing %q", kind)
178 }
179 }
180 }
181
182 func TestDesktopWireEventTypeCoversSharedPayloadFields(t *testing.T) {
183 ts := readDesktopTypes(t)
184 for _, want := range []string{
185 "detail?: string;",
186 "outcome?:",
187 `"completed" | "partial" | "blocked"`,
188 `"final_readiness" | "recovery_paused"`,
189 "checkpointTurn?: number;",
190 "retryAttempt?: number;", "WireEvent extends RecoveryEventFields", "recovery?: RecoveryStatus;",
191 "retryMax?: number;",
192 "retryScope?:",
193 "streamAttempt?: WireStreamAttempt;",
194 "export interface WireStreamAttempt",
195 "attemptId?: string;",
196 "contextPromptTokens?: number;",
197 "contextCompletionTokens?: number;",
198 "memoryCitations?: MemoryCitation[];",
199 "export interface MemoryCitation",
200 "resolvedName?: string;",
201 "capabilityId?: string;",
202 "cacheDiagnostics?: WireCacheDiagnostics;",
203 "export interface WireCacheDiagnostics",
204 "prefixHash: string;",
205 "prefixChanged: boolean;",
206 "prefixChangeReasons?: string[];",
207 "toolSchemaTokens: number;",
208 `sessionContext?: import("./sessionContextTypes").WireSessionContextDiagnostics;`,
209 "export interface WireSessionContextDiagnostics",
210 "targetRole:",
211 "backgroundMemory: WireSessionContextSectionDiagnostics;",
212 } {
213 if !strings.Contains(ts, want) {
214 t.Fatalf("desktop WireEvent types are missing %q", want)
215 }
216 }
217 }
218
219 func TestToWireNoticeDetail(t *testing.T) {
220 w := ToWire(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "short", Detail: "diagnostics"})
221 if w.Kind != "notice" || w.Level != "warn" || w.Text != "short" || w.Detail != "diagnostics" {
222 t.Fatalf("wire notice = %+v", w)
223 }
224 b, err := json.Marshal(w)
225 if err != nil {
226 t.Fatalf("marshal: %v", err)
227 }
228 for _, want := range []string{`"kind":"notice"`, `"text":"short"`, `"detail":"diagnostics"`, `"level":"warn"`} {
229 if !strings.Contains(string(b), want) {
230 t.Fatalf("notice JSON = %s, want it to contain %s", string(b), want)
231 }
232 }
233 }
234
235 func TestToWireToolCarriesResolvedCapabilityMetadata(t *testing.T) {
236 w := ToWire(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
237 ID: "c1", Name: "use_capability",
238 Args: `{"action":"call","capability_id":"mcp-tool:db/write"}`,
239 ResolvedName: "mcp__db__write", CapabilityID: "mcp-tool:db/write",
240 ReadOnly: false, Refreshed: true,
241 }})
242 b, err := json.Marshal(w)
243 if err != nil {
244 t.Fatalf("marshal: %v", err)
245 }
246 for _, want := range []string{
247 `"name":"use_capability"`, `"resolvedName":"mcp__db__write"`,
248 `"capabilityId":"mcp-tool:db/write"`, `"readOnly":false`, `"refreshed":true`,
249 } {
250 if !strings.Contains(string(b), want) {
251 t.Fatalf("tool JSON = %s, want %s", b, want)
252 }
253 }
254 }
255
256 func TestToWireTodoResultPreservesExplicitEmptyList(t *testing.T) {
257 w := ToWire(event.Event{Kind: event.ToolResult, Tool: event.Tool{
258 ID: "todo-1", Name: "todo_write", TodoWritten: true, Todos: []event.Todo{},
259 }})
260 b, err := json.Marshal(w)
261 if err != nil {
262 t.Fatal(err)
263 }
264 if !strings.Contains(string(b), `"todoWritten":true`) || !strings.Contains(string(b), `"todos":[]`) {
265 t.Fatalf("explicit empty todo state was lost: %s", b)
266 }
267 }
268
269 func TestToWireToolCarriesSubagentOutcomeMetadata(t *testing.T) {
270 w := ToWire(event.Event{Kind: event.ToolResult, Tool: event.Tool{
271 ID: "skill-1", Name: "run_skill", SubagentRef: "sa_child",
272 SubagentStatus: "partial", SubagentErrorCode: "completion_uncertain", SubagentRetryable: true,
273 }})
274 b, err := json.Marshal(w)
275 if err != nil {
276 t.Fatalf("marshal: %v", err)
277 }
278 for _, want := range []string{`"subagentRef":"sa_child"`, `"subagentStatus":"partial"`, `"subagentErrorCode":"completion_uncertain"`, `"subagentRetryable":true`} {
279 if !strings.Contains(string(b), want) {
280 t.Fatalf("subagent outcome JSON = %s, want %s", b, want)
281 }
282 }
283 }
284
285 func TestToWireToolOmitsHostOnlyWorkspaceMutationMetadata(t *testing.T) {
286 privatePath := "/Users/private/secret-project/file.go"
287 w := ToWire(event.Event{Kind: event.ToolResult, Tool: event.Tool{
288 ID: "c1", Name: "write_file", WorkspaceMutation: true,
289 WorkspacePaths: []string{privatePath}, WorkspaceAllPaths: true,
290 }})
291 b, err := json.Marshal(w)
292 if err != nil {
293 t.Fatalf("marshal: %v", err)
294 }
295 if strings.Contains(string(b), privatePath) || strings.Contains(string(b), "workspaceMutation") || strings.Contains(string(b), "workspacePaths") {
296 t.Fatalf("host-only workspace metadata leaked into eventwire JSON: %s", b)
297 }
298 }
299
300 func TestToWireTurnOutcomeIsOptionalAndMachineReadable(t *testing.T) {
301 readiness := ToWire(event.Event{
302 Kind: event.TurnDone,
303 Err: errors.New("final-answer readiness failed 3 times: missing verification"),
304 Outcome: event.TurnOutcomeFinalReadiness,
305 Readiness: &event.FinalReadiness{Attempts: 3, Missing: []string{"verification", "review"}},
306 })
307 if readiness.Outcome != event.TurnOutcomeFinalReadiness || readiness.Err == "" || readiness.Readiness == nil || readiness.Readiness.Attempts != 3 {
308 t.Fatalf("readiness wire event = %+v", readiness)
309 }
310 b, err := json.Marshal(readiness)
311 if err != nil {
312 t.Fatalf("marshal readiness: %v", err)
313 }
314 if !strings.Contains(string(b), `"outcome":"final_readiness"`) || !strings.Contains(string(b), `"missing":["verification","review"]`) {
315 t.Fatalf("readiness JSON = %s, want structured outcome", b)
316 }
317
318 ordinary, err := json.Marshal(ToWire(event.Event{Kind: event.TurnDone, Err: errors.New("provider failed")}))
319 if err != nil {
320 t.Fatalf("marshal ordinary error: %v", err)
321 }
322 if strings.Contains(string(ordinary), `"outcome"`) {
323 t.Fatalf("ordinary error JSON must omit outcome: %s", ordinary)
324 }
325 }
326
327 func TestToWireTurnDoneCheckpointTurnPreservesZeroAndOmitsNil(t *testing.T) {
328 turn := 0
329 withCheckpoint, err := json.Marshal(ToWire(event.Event{Kind: event.TurnDone, CheckpointTurn: &turn}))
330 if err != nil {
331 t.Fatalf("marshal checkpoint turn: %v", err)
332 }
333 if !strings.Contains(string(withCheckpoint), `"checkpointTurn":0`) {
334 t.Fatalf("checkpoint JSON = %s, want turn zero", withCheckpoint)
335 }
336
337 withoutCheckpoint, err := json.Marshal(ToWire(event.Event{Kind: event.TurnDone}))
338 if err != nil {
339 t.Fatalf("marshal empty TurnDone: %v", err)
340 }
341 if strings.Contains(string(withoutCheckpoint), `"checkpointTurn"`) {
342 t.Fatalf("empty TurnDone JSON must omit checkpointTurn: %s", withoutCheckpoint)
343 }
344 }
345
346 func TestToWireMessageMemoryCitations(t *testing.T) {
347 w := ToWire(event.Event{
348 Kind: event.Message,
349 Text: "done",
350 MemoryCitations: []provider.MemoryCitation{{
351 ID: "mem-1",
352 Source: "MEMORY.md",
353 LineStart: 116,
354 LineEnd: 123,
355 Note: "reasonix workflow",
356 Kind: "memory_reference",
357 }},
358 })
359 if len(w.MemoryCitations) != 1 {
360 t.Fatalf("memory citations = %+v, want one citation", w.MemoryCitations)
361 }
362 got := w.MemoryCitations[0]
363 if got.Source != "MEMORY.md" || got.LineStart != 116 || got.LineEnd != 123 || got.Note != "reasonix workflow" {
364 t.Fatalf("citation = %+v, want source/line/note preserved", got)
365 }
366 b, err := json.Marshal(w)
367 if err != nil {
368 t.Fatalf("marshal: %v", err)
369 }
370 if !strings.Contains(string(b), `"memoryCitations":[`) {
371 t.Fatalf("wire JSON missing memoryCitations: %s", string(b))
372 }
373 }
374
375 func readDesktopTypes(t *testing.T) string {
376 t.Helper()
377 _, file, _, ok := runtime.Caller(0)
378 if !ok {
379 t.Fatal("runtime caller unavailable")
380 }
381 dir := filepath.Join(filepath.Dir(file), "..", "..", "desktop", "frontend", "src", "lib")
382 var source strings.Builder
383 for _, name := range []string{"types.ts", "sessionContextTypes.ts", "recoveryStatus.ts"} {
384 b, err := os.ReadFile(filepath.Join(dir, name))
385 if err != nil {
386 t.Fatalf("read desktop type %s: %v", name, err)
387 }
388 source.Write(b)
389 }
390 return source.String()
391 }
392
393 func TestToWireToolPayloadJSON(t *testing.T) {
394 w := ToWire(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
395 ID: "call-1", Name: "task", Args: `{"prompt":"x"}`, Output: "ignored",
396 Err: "blocked", ReadOnly: true, Truncated: true, DurationMs: 522,
397 StartedAt: 1754500000000, EndedAt: 1754500000522,
398 Partial: true, Refreshed: true, ParentID: "parent-1",
399 FileDiff: event.FileDiff{Diff: "@@ -1 +1 @@\n-old\n+new\n", Added: 1, Removed: 1},
400 Profile: &event.Profile{Model: "deepseek-pro", Effort: "max"},
401 }})
402 b, err := json.Marshal(w)
403 if err != nil {
404 t.Fatalf("marshal: %v", err)
405 }
406 s := string(b)
407 for _, want := range []string{
408 `"kind":"tool_dispatch"`, `"id":"call-1"`, `"name":"task"`,
409 `"args":"{\"prompt\":\"x\"}"`, `"output":"ignored"`, `"err":"blocked"`,
410 `"readOnly":true`, `"truncated":true`, `"durationMs":522`, `"partial":true`, `"refreshed":true`,
411 `"startedAt":1754500000000`, `"endedAt":1754500000522`,
412 `"parentId":"parent-1"`, `"diff":"@@ -1 +1 @@\n-old\n+new\n"`,
413 `"added":1`, `"removed":1`, `"profile":{"model":"deepseek-pro","effort":"max"}`,
414 } {
415 if !strings.Contains(s, want) {
416 t.Fatalf("tool JSON = %s, want it to contain %s", s, want)
417 }
418 }
419 }
420
421 func TestToWireUsagePayloadJSON(t *testing.T) {
422 w := ToWire(event.Event{
423 Kind: event.Usage,
424 Usage: &provider.Usage{
425 PromptTokens: 1000, CompletionTokens: 200, TotalTokens: 1200,
426 CacheHitTokens: 900, CacheMissTokens: 100, ReasoningTokens: 33, Estimated: true,
427 },
428 Pricing: &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2},
429 UsageSource: event.UsageSourceTitle,
430 CacheDiagnostics: &event.CacheDiagnostics{
431 PrefixHash: "p", PrefixChanged: true, PrefixChangeReasons: []string{"log_rewrite"},
432 SystemHash: "s", ToolsHash: "t", LogRewriteVersion: 1, ToolSchemaTokens: 42,
433 CacheMissTokens: 100, CacheHitTokens: 900,
434 SessionContext: &event.SessionContextDiagnostics{
435 Version: 1, Digest: strings.Repeat("a", 64), TargetRole: "executor", Reasons: []string{"memory_changed"},
436 BackgroundMemory: event.SessionContextSectionDiagnostics{Digest: strings.Repeat("b", 64), Chars: 23},
437 },
438 },
439 SessionHit: 8000, SessionMiss: 2000,
440 })
441 b, err := json.Marshal(w)
442 if err != nil {
443 t.Fatalf("marshal: %v", err)
444 }
445 s := string(b)
446 for _, want := range []string{
447 `"kind":"usage"`, `"promptTokens":1000`, `"completionTokens":200`, `"totalTokens":1200`,
448 `"cacheHitTokens":900`, `"cacheMissTokens":100`, `"reasoningTokens":33`,
449 `"estimated":true`,
450 `"source":"title"`, `"sessionCacheHitTokens":8000`, `"sessionCacheMissTokens":2000`,
451 `"currency":"¥"`, `"costUsd":`, `"cacheDiagnostics":`, `"prefixHash":"p"`,
452 `"prefixChanged":true`, `"prefixChangeReasons":["log_rewrite"]`, `"toolSchemaTokens":42`,
453 `"sessionContext":`, `"targetRole":"executor"`, `"reasons":["memory_changed"]`, `"chars":23`,
454 } {
455 if !strings.Contains(s, want) {
456 t.Fatalf("usage JSON = %s, want it to contain %s", s, want)
457 }
458 }
459 }
460
461 func TestToWireInteractionAndLifecyclePayloads(t *testing.T) {
462 tests := []struct {
463 name string
464 in event.Event
465 want []string
466 }{
467 {
468 name: "approval",
469 in: event.Event{Kind: event.ApprovalRequest, TurnID: "turn-a", ItemID: "a1", Approval: event.Approval{ID: "a1", Tool: "bash", Subject: "rm", TurnID: "turn-a"}},
470 want: []string{`"kind":"approval_request"`, `"promptId":"a1"`, `"promptKind":"approval"`, `"turnId":"turn-a"`, `"approval":{"id":"a1"`, `"tool":"bash"`, `"subject":"rm"`},
471 },
472 {
473 name: "fresh approval",
474 in: event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a2", Tool: "mcp__srv__wipe", Subject: "srv/wipe", Fresh: true}},
475 want: []string{`"kind":"approval_request"`, `"tool":"mcp__srv__wipe"`, `"fresh":true`},
476 },
477 {
478 name: "recovery task grant",
479 in: event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
480 ID: "r1", Tool: "bash", Subject: "git push origin feature", Fresh: true, Kind: "recovery",
481 Recovery: &event.RecoveryApproval{
482 NextAction: "git push origin feature", CanGrantTask: true,
483 TaskGrantScope: "git push origin → feature",
484 },
485 }},
486 want: []string{
487 `"kind":"recovery"`, `"next_action":"git push origin feature"`, `"can_grant_task":true`,
488 `"task_grant_scope":"git push origin → feature"`,
489 },
490 },
491 {
492 name: "recovery plan transition",
493 in: event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
494 ID: "r-plan", Tool: "todo_write", Subject: "Update the active execution plan", Fresh: true, Kind: "recovery",
495 Recovery: &event.RecoveryApproval{
496 ChangeKind: "scope", PlanBefore: "1. Keep API", PlanAfter: "1. Replace API",
497 },
498 }},
499 want: []string{
500 `"kind":"recovery"`, `"change_kind":"scope"`,
501 `"plan_before":"1. Keep API"`, `"plan_after":"1. Replace API"`,
502 },
503 },
504 {
505 name: "ask",
506 in: event.Event{Kind: event.AskRequest, TurnID: "turn-q", ItemID: "ask-1", Ask: event.Ask{
507 ID: "ask-1",
508 TurnID: "turn-q",
509 Questions: []event.AskQuestion{{
510 ID: "q1", Header: "Pick", Prompt: "Choose", Multi: true,
511 Options: []event.AskOption{{Label: "A", Description: "Alpha"}, {Label: "B"}},
512 }},
513 }},
514 want: []string{`"kind":"ask_request"`, `"promptId":"ask-1"`, `"promptKind":"ask"`, `"turnId":"turn-q"`, `"ask":{"id":"ask-1"`, `"header":"Pick"`, `"description":"Alpha"`, `"multi":true`},
515 },
516 {
517 name: "compaction",
518 in: event.Event{Kind: event.CompactionDone, Compaction: event.Compaction{
519 Trigger: "manual", Messages: 7, Summary: "brief", Archive: "/tmp/archive.jsonl",
520 }},
521 want: []string{`"kind":"compaction_done"`, `"trigger":"manual"`, `"messages":7`, `"summary":"brief"`, `"archive":"/tmp/archive.jsonl"`},
522 },
523 {
524 name: "turn done error",
525 in: event.Event{Kind: event.TurnDone, Err: errors.New("boom")},
526 want: []string{`"kind":"turn_done"`, `"err":"boom"`},
527 },
528 {
529 name: "steer",
530 in: event.Event{Kind: event.Steer, Text: "mid-turn guidance"},
531 want: []string{`"kind":"steer"`, `"text":"mid-turn guidance"`},
532 },
533 }
534 for _, tt := range tests {
535 t.Run(tt.name, func(t *testing.T) {
536 b, err := json.Marshal(ToWire(tt.in))
537 if err != nil {
538 t.Fatalf("marshal: %v", err)
539 }
540 s := string(b)
541 for _, want := range tt.want {
542 if !strings.Contains(s, want) {
543 t.Fatalf("%s JSON = %s, want it to contain %s", tt.name, s, want)
544 }
545 }
546 })
547 }
548 }
549
550 func TestPromptWireMarksLegacyIdentity(t *testing.T) {
551 w := ToWire(event.Event{Kind: event.AskRequest, ItemID: "legacy-ask", Ask: event.Ask{ID: "legacy-ask"}})
552 if !w.PromptLegacy || w.PromptID != "legacy-ask" || w.PromptKind != "ask" {
553 t.Fatalf("legacy prompt wire identity = %+v", w)
554 }
555 }
556
556 lines GO