package protocol import ( "encoding/json" "testing" ) // Golden wire frames as emitted by the Kotlin server (kotlinx.serialization). These // strings are the cross-language contract: the Kotlin side pins the encode in // ServerMessageSerializationTest; this test pins the Go decode of the exact same bytes, // so a discriminator (@SerialName) or field-name drift fails loudly on one side. func TestDecodeGoldenServerFrames(t *testing.T) { cases := []struct { name string json string wantType string eventBear bool check func(t *testing.T, m ServerMessage) }{ { name: "session.announced", json: `{"type":"session.announced","sessionId":"s1","workflowId":"healthcheck","sequence":6,"sessionSequence":1}`, wantType: TypeSessionAnnounced, eventBear: true, check: func(t *testing.T, m ServerMessage) { if m.SessionID != "s1" || m.WorkflowID != "healthcheck" { t.Fatalf("session.announced fields: %+v", m) } }, }, { name: "chat.turn", json: `{"type":"chat.turn","sessionId":"s1","turnId":"t9","role":"ROUTER","content":"hi there","sequence":7,"sessionSequence":2}`, wantType: TypeChatTurn, eventBear: true, check: func(t *testing.T, m ServerMessage) { if m.Role != "ROUTER" || m.Content != "hi there" || m.TurnID != "t9" { t.Fatalf("chat.turn fields: %+v", m) } }, }, { name: "stage.started", json: `{"type":"stage.started","sessionId":"s1","stageId":"write_script","occurredAt":123,"sequence":8,"sessionSequence":3}`, wantType: TypeStageStarted, eventBear: true, check: func(t *testing.T, m ServerMessage) { if m.StageID != "write_script" { t.Fatalf("stage.started fields: %+v", m) } }, }, { name: "tool.completed", json: `{"type":"tool.completed","sessionId":"s1","toolName":"shell","outputSummary":"ok","occurredAt":1,"diff":null,"sequence":9,"sessionSequence":4}`, wantType: TypeToolCompleted, eventBear: true, check: func(t *testing.T, m ServerMessage) { if m.ToolName != "shell" || m.Summary != "ok" { t.Fatalf("tool.completed fields: %+v", m) } }, }, { // Plane-2 rationale (verified preconditions) must survive decode — it is the // justification shown in the approval band, not the opaque tier. name: "approval.required carries plane-2 rationale", json: `{"type":"approval.required","sessionId":"s1","requestId":"req-1","tier":"T3","riskSummary":{"level":"MEDIUM","factors":[],"recommendedAction":"PROMPT_USER","rationale":["[PATH_OUTSIDE_WORKSPACE] outside: /tmp/x"]},"toolName":"file_write","preview":"--- a/x\n+++ b/x","sequence":12,"sessionSequence":3}`, wantType: TypeApprovalRequired, eventBear: true, check: func(t *testing.T, m ServerMessage) { if m.RiskSummary == nil { t.Fatalf("approval.required: riskSummary decoded nil") } if len(m.RiskSummary.Rationale) != 1 || m.RiskSummary.Rationale[0] != "[PATH_OUTSIDE_WORKSPACE] outside: /tmp/x" { t.Fatalf("rationale not decoded: %+v", m.RiskSummary) } }, }, { name: "session.workspace_bound carries the cwd", json: `{"type":"session.workspace_bound","sessionId":"s1","workspaceRoot":"/home/kami/Programs/correx","sequence":4,"sessionSequence":1}`, wantType: TypeWorkspaceBound, eventBear: true, check: func(t *testing.T, m ServerMessage) { if m.WorkspaceRoot != "/home/kami/Programs/correx" { t.Fatalf("workspaceRoot not decoded: %+v", m) } }, }, { name: "chat.turn carries router metrics", json: `{"type":"chat.turn","sessionId":"s1","turnId":"t9","role":"ROUTER","content":"hi","latencyMs":3000,"totalTokens":943,"sequence":7,"sessionSequence":2}`, wantType: TypeChatTurn, eventBear: true, check: func(t *testing.T, m ServerMessage) { if m.LatencyMs == nil || *m.LatencyMs != 3000 || m.TotalTokens == nil || *m.TotalTokens != 943 { t.Fatalf("chat.turn metrics: %+v", m) } }, }, { name: "router.narration carries content + metrics", json: `{"type":"router.narration","sessionId":"s1","content":"moving on","stageId":"validate","latencyMs":2000,"totalTokens":120,"sequence":10,"sessionSequence":5}`, wantType: TypeRouterNarration, eventBear: true, check: func(t *testing.T, m ServerMessage) { if m.Content != "moving on" || m.LatencyMs == nil || *m.LatencyMs != 2000 || m.TotalTokens == nil || *m.TotalTokens != 120 { t.Fatalf("router.narration: %+v", m) } }, }, { name: "snapshot_complete (non-event control frame)", json: `{"type":"snapshot_complete"}`, wantType: TypeSnapshotComplete, eventBear: false, }, { name: "session.stats carries nested derived metrics", json: `{"type":"session.stats","sessionId":"s1","stats":{"sessionId":"s1","eventCount":12,` + `"sessionDurationMs":60000,"inferenceCount":3,"inferenceMs":6000,"promptTokens":310,` + `"completionTokens":170,"tokensPerSecond":28.3,` + `"perProvider":[{"provider":"lfm:a","completedCount":2,"totalLatencyMs":5000,` + `"promptTokens":300,"completionTokens":150,"tokensPerSecond":30.0}],` + `"toolCount":3,"toolMs":1500,"perTool":[{"toolName":"shell.read","completedCount":2,"totalDurationMs":1200}],` + `"approvalsRequested":2,"approvalsResolved":1,"approvalsPending":1,"approvalWaitMs":30000,` + `"avgApprovalWaitMs":30000,"perTier":[{"tier":"T2","requestedCount":2,"resolvedCount":1,` + `"totalWaitMs":30000,"avgWaitMs":30000}],` + `"failures":{"inferenceFailures":1,"inferenceTimeouts":0,"toolFailures":1,"toolRejections":0,` + `"stageFailures":0,"workflowFailures":1},"inferencePct":10.0,"toolPct":2.5,"approvalWaitPct":50.0}}`, wantType: TypeSessionStats, eventBear: false, check: func(t *testing.T, m ServerMessage) { if m.SessionID != "s1" { t.Fatalf("session.stats sessionId: %+v", m) } if m.Stats == nil { t.Fatalf("session.stats: Stats is nil") } if m.Stats.InferenceCount != 3 || m.Stats.CompletionTokens != 170 { t.Fatalf("session.stats inference totals: %+v", m.Stats) } if len(m.Stats.PerProvider) != 1 || m.Stats.PerProvider[0].Provider != "lfm:a" { t.Fatalf("session.stats perProvider: %+v", m.Stats.PerProvider) } if m.Stats.Failures.WorkflowFailures != 1 || m.Stats.ApprovalWaitPct != 50.0 { t.Fatalf("session.stats failures/pct: %+v", m.Stats) } }, }, { name: "idea.list carries the cross-session board", json: `{"type":"idea.list","ideas":[{"ideaId":"i1","text":"cache the repo map","capturedAtMs":1000},` + `{"ideaId":"i2","text":"add --dry-run","capturedAtMs":2000}]}`, wantType: TypeIdeaList, eventBear: false, check: func(t *testing.T, m ServerMessage) { if len(m.Ideas) != 2 { t.Fatalf("idea.list ideas: %+v", m.Ideas) } if m.Ideas[0].IdeaID != "i1" || m.Ideas[0].Text != "cache the repo map" || m.Ideas[0].CapturedAtMs != 1000 { t.Fatalf("idea.list idea[0]: %+v", m.Ideas[0]) } }, }, { name: "health.checks carries nested health report", json: `{"type":"health.checks","health":{"overall":"DEGRADED","subjects":[` + `{"subject":"DISK","status":"DEGRADED","metric":"free_bytes","observedValue":512000000,` + `"detail":"disk free below 1 GB threshold","since":"2026-06-14T10:00:00Z"},` + `{"subject":"EVENT_STORE","status":"HEALTHY","metric":"write_latency_ms","observedValue":4,` + `"detail":"ok","since":"2026-06-14T09:00:00Z"}],` + `"checkedAt":"2026-06-14T10:05:00Z"}}`, wantType: TypeHealthChecks, eventBear: false, check: func(t *testing.T, m ServerMessage) { if m.Health == nil { t.Fatalf("health.checks: Health is nil") } if m.Health.Overall != "DEGRADED" { t.Fatalf("health.checks overall = %q, want DEGRADED", m.Health.Overall) } if len(m.Health.Subjects) != 2 { t.Fatalf("health.checks subjects len = %d, want 2", len(m.Health.Subjects)) } disk := m.Health.Subjects[0] if disk.Subject != "DISK" || disk.Status != "DEGRADED" || disk.ObservedValue != 512000000 { t.Fatalf("health.checks DISK subject: %+v", disk) } es := m.Health.Subjects[1] if es.Subject != "EVENT_STORE" || es.Status != "HEALTHY" { t.Fatalf("health.checks EVENT_STORE subject: %+v", es) } if m.Health.CheckedAt != "2026-06-14T10:05:00Z" { t.Fatalf("health.checks checkedAt = %q", m.Health.CheckedAt) } }, }, { name: "workflow.proposed carries candidate workflows", json: `{"type":"workflow.proposed","sessionId":"s1","proposalId":"prop-1","prompt":"Run one?",` + `"candidates":[{"workflowId":"research","reason":"gather + report"},{"workflowId":"role_pipeline","reason":""}],` + `"originalRequest":"find papers","sequence":9,"sessionSequence":4}`, wantType: TypeWorkflowProposed, eventBear: true, check: func(t *testing.T, m ServerMessage) { if m.ProposalID != "prop-1" || m.Prompt != "Run one?" || m.OriginalRequest != "find papers" { t.Fatalf("workflow.proposed fields: %+v", m) } if len(m.Candidates) != 2 { t.Fatalf("workflow.proposed candidates: %+v", m.Candidates) } if m.Candidates[0].WorkflowID != "research" || m.Candidates[0].Reason != "gather + report" { t.Fatalf("workflow.proposed candidate[0]: %+v", m.Candidates[0]) } }, }, { name: "clarification.required carries structured questions", json: `{"type":"clarification.required","sessionId":"s1","requestId":"req-1","stageId":"analyst",` + `"questions":[{"id":"stack","prompt":"Which stack?","options":["React","Vue"],"multiSelect":false,"header":"Stack"}],` + `"sequence":9,"sessionSequence":4}`, wantType: TypeClarification, eventBear: true, check: func(t *testing.T, m ServerMessage) { if m.RequestID != "req-1" || m.StageID != "analyst" { t.Fatalf("clarification ids: %+v", m) } if len(m.Questions) != 1 { t.Fatalf("clarification questions: %+v", m.Questions) } q := m.Questions[0] if q.Prompt != "Which stack?" || q.Header != "Stack" || len(q.Options) != 2 || q.Options[0] != "React" { t.Fatalf("clarification question fields: %+v", q) } }, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { m, err := Decode([]byte(c.json)) if err != nil { t.Fatalf("decode failed: %v", err) } if m.Type != c.wantType { t.Fatalf("type = %q, want %q", m.Type, c.wantType) } if m.IsEventBearing() != c.eventBear { t.Fatalf("IsEventBearing = %v, want %v", m.IsEventBearing(), c.eventBear) } if c.check != nil { c.check(t, m) } }) } } // TestEncodeGoldenClientFrames pins the Go→Kotlin wire format for client messages. // The discriminator is the fully-qualified Kotlin class name (kotlinx default for // sealed classes without @SerialName), keyed under "type". These bytes must be // accepted verbatim by the Kotlin server's ProtocolSerializer / Json decoder. func TestEncodeGoldenClientFrames(t *testing.T) { cases := []struct { name string frame []byte wantType string check func(t *testing.T, raw map[string]any) }{ { // Hello is the first frame on every (re)connect — carries the client cwd so // the server can bind a workspace before any session starts. name: "Hello carries workingDir", frame: Hello("/home/kami/Projects/correx"), wantType: clientPrefix + "Hello", check: func(t *testing.T, raw map[string]any) { if raw["workingDir"] != "/home/kami/Projects/correx" { t.Fatalf("Hello workingDir = %v", raw["workingDir"]) } }, }, { name: "GetHealthChecks encodes correctly", frame: GetHealthChecks(), wantType: clientPrefix + "GetHealthChecks", }, { name: "DiscardIdea carries the ideaId", frame: DiscardIdea("i1"), wantType: clientPrefix + "DiscardIdea", check: func(t *testing.T, raw map[string]any) { if raw["ideaId"] != "i1" { t.Fatalf("DiscardIdea ideaId: %+v", raw) } }, }, { name: "ClarificationResponse carries ids + answers", frame: ClarificationResponse("s1", "analyst", "req-1", []ClarificationAnswerDto{{QuestionID: "stack", Value: "React"}}), wantType: clientPrefix + "ClarificationResponse", check: func(t *testing.T, raw map[string]any) { if raw["sessionId"] != "s1" || raw["stageId"] != "analyst" || raw["requestId"] != "req-1" { t.Fatalf("ClarificationResponse ids: %+v", raw) } answers, ok := raw["answers"].([]any) if !ok || len(answers) != 1 { t.Fatalf("ClarificationResponse answers: %+v", raw["answers"]) } a := answers[0].(map[string]any) if a["questionId"] != "stack" || a["value"] != "React" { t.Fatalf("ClarificationResponse answer: %+v", a) } }, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { var raw map[string]any if err := json.Unmarshal(c.frame, &raw); err != nil { t.Fatalf("unmarshal failed: %v", err) } if raw["type"] != c.wantType { t.Fatalf("type = %q, want %q", raw["type"], c.wantType) } if c.check != nil { c.check(t, raw) } }) } }