package bridge // event_serializer_test.go — locks turn_start / turn_end payload shape. // // Without these tests the serializer's anonymous struct literals silently // drifted from the underlying flyto.Event fields (ContextWindowTokens and // MaxTokens sat as dead fields for weeks). Asserting the full JSON keyset // keeps new event fields from disappearing behind the bridge choke point. // // event_serializer_test.go — 锁定 turn_start / turn_end 的 JSON payload 形状. // // 没有这些测试时, serializer 的 anonymous struct literal 与底层 // flyto.Event 字段会静默漂移 (ContextWindowTokens 和 MaxTokens 曾作 // dead 字段数周). 断言完整 JSON key 集合, 防新事件字段被 bridge // choke point 吞掉. import ( "encoding/json" "testing" "time" "git.flytoex.net/yuanwei/flyto-agent/pkg/engine" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" ) // TestSerialize_TurnStart_ShapeIncludesContextWindow asserts the turn_start // payload carries ContextWindowTokens as the `context_window` JSON key. // // TestSerialize_TurnStart_ShapeIncludesContextWindow 断言 turn_start // payload 以 `context_window` JSON key 透传 ContextWindowTokens. func TestSerialize_TurnStart_ShapeIncludesContextWindow(t *testing.T) { s := NewEventSerializer("sess1") evt := &flyto.TurnStartEvent{ TurnNumber: 3, Model: "claude-opus-4-6", ContextWindowTokens: 200_000, } bridgeEvt := s.Serialize((*engine.TurnStartEvent)(evt)) if bridgeEvt.Type != "turn_start" { t.Fatalf("Type = %q, want turn_start", bridgeEvt.Type) } var got struct { Turn int `json:"turn"` Model string `json:"model"` ContextWindow int `json:"context_window"` } if err := json.Unmarshal(bridgeEvt.Payload, &got); err != nil { t.Fatalf("payload json: %v", err) } if got.Turn != 3 { t.Errorf("turn = %d, want 3", got.Turn) } if got.Model != "claude-opus-4-6" { t.Errorf("model = %q, want claude-opus-4-6", got.Model) } if got.ContextWindow != 200_000 { t.Errorf("context_window = %d, want 200000", got.ContextWindow) } } // TestSerialize_TurnStart_ContextWindowOmittedWhenZero ensures omitempty // kicks in so consumers unaware of the field aren't spammed with zeros // from older event emitters. // // TestSerialize_TurnStart_ContextWindowOmittedWhenZero 确保 omitempty // 生效 — 字段为零时不出现在 JSON, 旧事件发射端不影响不感知此字段的消费者. func TestSerialize_TurnStart_ContextWindowOmittedWhenZero(t *testing.T) { s := NewEventSerializer("sess1") evt := &flyto.TurnStartEvent{TurnNumber: 1, Model: "m"} bridgeEvt := s.Serialize((*engine.TurnStartEvent)(evt)) if got := string(bridgeEvt.Payload); got == "" { t.Fatal("payload empty") } else if contains(got, "context_window") { t.Errorf("zero context_window 应被 omitempty 省略, payload = %s", got) } } // TestSerialize_TurnEnd_ShapeIncludesMaxTokens asserts the turn_end payload // carries MaxTokens as the `max_tokens` JSON key. // // TestSerialize_TurnEnd_ShapeIncludesMaxTokens 断言 turn_end payload // 以 `max_tokens` JSON key 透传 MaxTokens. func TestSerialize_TurnEnd_ShapeIncludesMaxTokens(t *testing.T) { s := NewEventSerializer("sess1") evt := &flyto.TurnEndEvent{ TurnNumber: 2, InputTokens: 1500, OutputTokens: 800, CostUSD: 0.12, MaxTokens: 8192, } bridgeEvt := s.Serialize((*engine.TurnEndEvent)(evt)) if bridgeEvt.Type != "turn_end" { t.Fatalf("Type = %q, want turn_end", bridgeEvt.Type) } var got struct { Turn int `json:"turn"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CostUSD float64 `json:"cost_usd"` MaxTokens int `json:"max_tokens"` } if err := json.Unmarshal(bridgeEvt.Payload, &got); err != nil { t.Fatalf("payload json: %v", err) } if got.Turn != 2 || got.InputTokens != 1500 || got.OutputTokens != 800 { t.Errorf("turn/input/output 漂移: %+v", got) } if got.CostUSD != 0.12 { t.Errorf("cost_usd = %v, want 0.12", got.CostUSD) } if got.MaxTokens != 8192 { t.Errorf("max_tokens = %d, want 8192", got.MaxTokens) } } // TestSerialize_TurnEnd_MaxTokensOmittedWhenZero same omitempty guarantee // as TurnStart context_window. // // TestSerialize_TurnEnd_MaxTokensOmittedWhenZero 与 TurnStart 的 omitempty // 保证相同. func TestSerialize_TurnEnd_MaxTokensOmittedWhenZero(t *testing.T) { s := NewEventSerializer("sess1") evt := &flyto.TurnEndEvent{TurnNumber: 1} bridgeEvt := s.Serialize((*engine.TurnEndEvent)(evt)) if contains(string(bridgeEvt.Payload), "max_tokens") { t.Errorf("zero max_tokens 应被 omitempty 省略, payload = %s", string(bridgeEvt.Payload)) } } // contains is a tiny strings.Contains alias to keep imports minimal. // // contains 是 strings.Contains 的微型别名, 减少 import. func contains(s, sub string) bool { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return true } } return false } // --------------------------------------------------------------------------- // SubAgent lifecycle + wrapping shape tests // // 子 agent 观测链路端到端 wire Commit C 补 bridge serializer 3 case. // 每个子测试锁一条 sub-claim: subagent_start / subagent_end / subagent_event // 扁平包装 shape 都不被 bridge choke point 吞掉. // // SubAgent observability commit C bridge serializer tests. Each sub-test // locks one sub-claim: subagent_start / subagent_end / subagent_event flat // wrapping shape are all carried through the bridge choke point. // --------------------------------------------------------------------------- // TestSerialize_SubAgentStart_Shape 断言 subagent_start payload 带 // subagent_id / description / cwd / model / start_time_ms 5 个 key. // // TestSerialize_SubAgentStart_Shape asserts subagent_start payload carries // all 5 keys: subagent_id / description / cwd / model / start_time_ms. func TestSerialize_SubAgentStart_Shape(t *testing.T) { s := NewEventSerializer("sess1") evt := &engine.SubAgentStartEvent{ SubAgentID: "sa-42", Description: "explore-files", Cwd: "/tmp/worktree/abc", Model: "claude-sonnet-4-6", StartTime: timeAt(1_712_000_000_000), } b := s.Serialize(evt) if b.Type != "subagent_start" { t.Fatalf("Type = %q, want subagent_start", b.Type) } var payload map[string]any if err := json.Unmarshal(b.Payload, &payload); err != nil { t.Fatalf("unmarshal: %v", err) } for _, k := range []string{"subagent_id", "description", "cwd", "model", "start_time_ms"} { if _, ok := payload[k]; !ok { t.Errorf("payload 缺 key %q: %s", k, b.Payload) } } if payload["subagent_id"] != "sa-42" { t.Errorf("subagent_id = %v, want sa-42", payload["subagent_id"]) } // start_time_ms 来自 UnixMilli, JSON 解出来是 float64 if got := int64(payload["start_time_ms"].(float64)); got != 1_712_000_000_000 { t.Errorf("start_time_ms = %d, want 1712000000000", got) } } // TestSerialize_SubAgentStart_OmitsEmpty — 反向 sub-claim: 空串字段 // (Description / Cwd / Model) 经 omitempty 不出现在 JSON. func TestSerialize_SubAgentStart_OmitsEmpty(t *testing.T) { s := NewEventSerializer("sess1") evt := &engine.SubAgentStartEvent{ SubAgentID: "sa-1", StartTime: timeAt(0), } b := s.Serialize(evt) raw := string(b.Payload) for _, k := range []string{"description", "cwd", "model"} { if contains(raw, `"`+k+`"`) { t.Errorf("空串字段 %q 应 omitempty, 但出现在 JSON: %s", k, raw) } } // subagent_id / start_time_ms 必须存在 (非 omitempty) if !contains(raw, `"subagent_id"`) || !contains(raw, `"start_time_ms"`) { t.Errorf("必填字段缺失: %s", raw) } } // TestSerialize_SubAgentEnd_Shape — sub-claim: subagent_end payload 带 // subagent_id / duration_ms / status / result / error 5 key, status 是 // SubAgentStatus 的 string 值. func TestSerialize_SubAgentEnd_Shape(t *testing.T) { s := NewEventSerializer("sess1") evt := &engine.SubAgentEndEvent{ SubAgentID: "sa-42", Duration: durationMS(1500), Status: engine.SubAgentStatusCompleted, Result: "done successfully", Error: "", } b := s.Serialize(evt) if b.Type != "subagent_end" { t.Fatalf("Type = %q, want subagent_end", b.Type) } var payload map[string]any if err := json.Unmarshal(b.Payload, &payload); err != nil { t.Fatalf("unmarshal: %v", err) } if int64(payload["duration_ms"].(float64)) != 1500 { t.Errorf("duration_ms = %v, want 1500", payload["duration_ms"]) } if payload["status"] != string(engine.SubAgentStatusCompleted) { t.Errorf("status = %v, want %q", payload["status"], engine.SubAgentStatusCompleted) } if payload["result"] != "done successfully" { t.Errorf("result = %v", payload["result"]) } // error 空串 → omitempty 不出现 raw := string(b.Payload) if contains(raw, `"error"`) { t.Errorf("error 空串应 omitempty, 但出现在 JSON: %s", raw) } } // TestSerialize_SubAgentEnd_ErrorPresent — sub-claim: Error 非空时 JSON 保 // 留 error 字段. func TestSerialize_SubAgentEnd_ErrorPresent(t *testing.T) { s := NewEventSerializer("sess1") evt := &engine.SubAgentEndEvent{ SubAgentID: "sa-1", Duration: durationMS(100), Status: engine.SubAgentStatusFailed, Error: "timeout after 30s", } b := s.Serialize(evt) var payload map[string]any if err := json.Unmarshal(b.Payload, &payload); err != nil { t.Fatalf("unmarshal: %v", err) } if payload["error"] != "timeout after 30s" { t.Errorf("error = %v, want timeout after 30s", payload["error"]) } } // TestSerialize_SubAgentEvent_FlatShape — sub-claim: SubAgentEvent 包装一个 // TextEvent 时, JSON 扁平合并为 {subagent_id, event_type: "text", text: ...}, // 无 nested inner 键. func TestSerialize_SubAgentEvent_FlatShape(t *testing.T) { s := NewEventSerializer("sess1") evt := &engine.SubAgentEvent{ SubAgentID: "sa-7", Inner: &engine.TextEvent{Text: "hello"}, } b := s.Serialize(evt) if b.Type != "subagent_event" { t.Fatalf("Type = %q, want subagent_event", b.Type) } var payload map[string]any if err := json.Unmarshal(b.Payload, &payload); err != nil { t.Fatalf("unmarshal: %v", err) } if payload["subagent_id"] != "sa-7" { t.Errorf("subagent_id = %v, want sa-7", payload["subagent_id"]) } if payload["event_type"] != "text" { t.Errorf("event_type = %v, want text", payload["event_type"]) } if payload["text"] != "hello" { t.Errorf("text = %v, want hello", payload["text"]) } // 不该有嵌套 "inner" / "payload" 键 if _, ok := payload["inner"]; ok { t.Errorf("扁平 shape 不该含 inner 键: %s", b.Payload) } } // TestSerialize_SubAgentEvent_ToolUseInner — sub-claim: 包装 ToolUseEvent 时 // id / name / input 扁平合入. func TestSerialize_SubAgentEvent_ToolUseInner(t *testing.T) { s := NewEventSerializer("sess1") evt := &engine.SubAgentEvent{ SubAgentID: "sa-3", Inner: &engine.ToolUseEvent{ ID: "tu-1", ToolName: "Bash", Input: map[string]any{"command": "ls"}, }, } b := s.Serialize(evt) var payload map[string]any if err := json.Unmarshal(b.Payload, &payload); err != nil { t.Fatalf("unmarshal: %v", err) } if payload["event_type"] != "tool_use" { t.Errorf("event_type = %v, want tool_use", payload["event_type"]) } if payload["id"] != "tu-1" { t.Errorf("id = %v, want tu-1", payload["id"]) } if payload["name"] != "Bash" { t.Errorf("name = %v, want Bash", payload["name"]) } input, ok := payload["input"].(map[string]any) if !ok || input["command"] != "ls" { t.Errorf("input 解包失败: %v", payload["input"]) } } // TestSerialize_SubAgentEvent_NestedSubAgent — sub-claim: Inner 为另一层 // SubAgentEvent 时, 外层 subagent_id 胜出, event_type 为内层 wrapper 的 // "subagent_event" (孙 agent 的 attribution 丢失 — 扁平 shape 的故意取舍, // 跨行业极罕见, 消费端要树形展示应基于 SubAgentStart 的 parent_id 链路). func TestSerialize_SubAgentEvent_NestedSubAgent(t *testing.T) { s := NewEventSerializer("sess1") inner := &engine.SubAgentEvent{ SubAgentID: "grandchild", Inner: &engine.TextEvent{Text: "nested"}, } outer := &engine.SubAgentEvent{ SubAgentID: "child", Inner: inner, } b := s.Serialize(outer) var payload map[string]any if err := json.Unmarshal(b.Payload, &payload); err != nil { t.Fatalf("unmarshal: %v", err) } // 外层 subagent_id 覆盖内层 — extras 写入后赢 if payload["subagent_id"] != "child" { t.Errorf("嵌套 subagent_id = %v, want child (外层胜)", payload["subagent_id"]) } if payload["event_type"] != "subagent_event" { t.Errorf("嵌套 event_type = %v, want subagent_event", payload["event_type"]) } } // timeAt 返回 UnixMilli 等于 ms 的 time.Time, 测试用. timeAt returns a // time.Time whose UnixMilli equals ms, for tests. func timeAt(ms int64) time.Time { return time.UnixMilli(ms) } // durationMS 返回 ms 毫秒的 time.Duration, 测试用. durationMS returns a // time.Duration of ms milliseconds, for tests. func durationMS(ms int64) time.Duration { return time.Duration(ms) * time.Millisecond } // TestSerialize_PlanApproval_Shape asserts plan_approval payload carries // SessionID / Plan / FilePath plus the Steps array with per-step fields // (id / description / tools / complexity / deps). Approve / Reject func // fields are intentionally dropped — functions aren't JSON-marshallable // and push callbacks only apply to in-process Go subscribers. // // TestSerialize_PlanApproval_Shape 断言 plan_approval payload 透传 // SessionID / Plan / FilePath + Steps 数组内部字段 (id / description / // tools / complexity / deps). Approve / Reject func 字段故意丢 — 函数 // 不可 JSON marshal, push 回调仅对进程内 Go 订阅者有效. func TestSerialize_PlanApproval_Shape(t *testing.T) { s := NewEventSerializer("sess1") evt := &engine.PlanApprovalEvent{ SessionID: "sess-x", Plan: "# Plan\n1. analyze\n2. fix", FilePath: "/tmp/plan.md", Steps: []engine.PlanStep{ {ID: "s1", Description: "analyze", Tools: []string{"Read", "Grep"}, Complexity: engine.ComplexityLow}, {ID: "s2", Description: "fix", Tools: []string{"Edit"}, Complexity: engine.ComplexityMedium, Deps: []string{"s1"}}, }, } bridgeEvt := s.Serialize(evt) if bridgeEvt.Type != "plan_approval" { t.Fatalf("Type = %q, want plan_approval", bridgeEvt.Type) } var payload struct { SessionID string `json:"session_id"` Plan string `json:"plan"` FilePath string `json:"file_path"` Steps []struct { ID string `json:"ID"` Description string `json:"Description"` Tools []string `json:"Tools"` Complexity string `json:"Complexity"` Deps []string `json:"Deps"` } `json:"steps"` } if err := json.Unmarshal(bridgeEvt.Payload, &payload); err != nil { t.Fatalf("unmarshal: %v", err) } if payload.SessionID != "sess-x" { t.Errorf("session_id = %q, want sess-x", payload.SessionID) } if payload.Plan != "# Plan\n1. analyze\n2. fix" { t.Errorf("plan = %q", payload.Plan) } if payload.FilePath != "/tmp/plan.md" { t.Errorf("file_path = %q", payload.FilePath) } if len(payload.Steps) != 2 { t.Fatalf("steps len = %d, want 2", len(payload.Steps)) } if payload.Steps[0].ID != "s1" || payload.Steps[1].ID != "s2" { t.Errorf("step IDs = %q,%q", payload.Steps[0].ID, payload.Steps[1].ID) } if payload.Steps[1].Deps == nil || payload.Steps[1].Deps[0] != "s1" { t.Errorf("step 2 Deps = %v, want [s1]", payload.Steps[1].Deps) } } // TestSerialize_PlanApproval_OmitEmpty asserts file_path and steps are // omitted from JSON when empty, trimming SSE payload for SDK callers that // only need session_id + plan. // // TestSerialize_PlanApproval_OmitEmpty 断言空时 file_path 和 steps 省略, // 让仅用 session_id + plan 的 SDK 消费者收到更小 payload. func TestSerialize_PlanApproval_OmitEmpty(t *testing.T) { s := NewEventSerializer("sess1") evt := &engine.PlanApprovalEvent{ SessionID: "sess-y", Plan: "just text", } bridgeEvt := s.Serialize(evt) raw := string(bridgeEvt.Payload) if containsKey(raw, "file_path") { t.Errorf("empty FilePath should be omitted, payload = %s", raw) } if containsKey(raw, "steps") { t.Errorf("empty Steps should be omitted, payload = %s", raw) } } // containsKey tests whether the JSON payload contains a top-level key // (minimal check — good enough for omitempty assertions). // // containsKey 测试 JSON payload 是否含指定顶层 key (最小检查, 足以支持 // omitempty 断言). func containsKey(raw, key string) bool { needle := `"` + key + `":` for i := 0; i+len(needle) <= len(raw); i++ { if raw[i:i+len(needle)] == needle { return true } } return false } // --------------------------------------------------------------------------- // ToolResultEvent.Truncated / StoredPath bridge wire -- tools 族收尾 commit. // // Bridge serializer's tool_result anonymous struct is the choke point: // adding Truncated / StoredPath to the Go struct is useless if this // literal drops them (same bug shape as L699 turn 边界族 C2). Lock the // JSON payload shape explicitly. // // bridge serializer 的 tool_result 匿名 struct 是 choke point: 只在 Go // struct 加 Truncated / StoredPath 字段没用, 这个 literal 漏掉同样会 (和 // L699 turn 边界族 C2 同形的 bug). 在这里显式锁 JSON payload shape. // --------------------------------------------------------------------------- // TestSerialize_ToolResult_TruncatedShape asserts tool_result payload // carries truncated=true + stored_path when the orchestrator persisted a // full result to an external location. // // TestSerialize_ToolResult_TruncatedShape 断言 orchestrator 把完整结果落盘 // 到外部位置时, tool_result payload 带 truncated=true + stored_path. func TestSerialize_ToolResult_TruncatedShape(t *testing.T) { s := NewEventSerializer("sess1") evt := &engine.ToolResultEvent{ ID: "call-1", ToolName: "Bash", Output: "[truncated: 300/10485760 bytes]", IsError: false, Truncated: true, StoredPath: "/tmp/flyto/results/call-1.txt", } b := s.Serialize(evt) if b.Type != "tool_result" { t.Fatalf("Type = %q, want tool_result", b.Type) } var payload map[string]any if err := json.Unmarshal(b.Payload, &payload); err != nil { t.Fatalf("unmarshal: %v", err) } for _, k := range []string{"id", "name", "output", "truncated", "stored_path"} { if _, ok := payload[k]; !ok { t.Errorf("payload missing key %q: %s", k, b.Payload) } } if payload["truncated"] != true { t.Errorf("truncated = %v, want true", payload["truncated"]) } if payload["stored_path"] != "/tmp/flyto/results/call-1.txt" { t.Errorf("stored_path = %v, want /tmp/flyto/results/call-1.txt", payload["stored_path"]) } } // TestSerialize_ToolResult_OmitsEmptyTruncation asserts default // Truncated=false + empty StoredPath are elided via omitempty -- avoids // adding noise to existing consumers who don't know about these fields. // // TestSerialize_ToolResult_OmitsEmptyTruncation 断言默认 Truncated=false + // 空串 StoredPath 经 omitempty 不出现 -- 避免给不认识这俩字段的既有消费者 // 添噪声. func TestSerialize_ToolResult_OmitsEmptyTruncation(t *testing.T) { s := NewEventSerializer("sess1") evt := &engine.ToolResultEvent{ ID: "call-2", ToolName: "Read", Output: "hello", IsError: false, } b := s.Serialize(evt) raw := string(b.Payload) if containsKey(raw, "truncated") { t.Errorf("Truncated=false should be omitted from JSON: %s", raw) } if containsKey(raw, "stored_path") { t.Errorf("empty StoredPath should be omitted from JSON: %s", raw) } }