Files
correx/apps/tui-go/internal/protocol/golden_test.go
T
kami 218a98034e feat(tui-go): interactive clarification question view
Render stage clarification questions as an AskUserQuestion-style form:
header chip, prompt, selectable option chips, and a free-text custom slot.
Arrow/hjkl nav, space to select, e for custom, enter to submit. Submit
guards on all-answered and sends ClarificationResponse; the parked stage
re-runs with the answers in context.
2026-06-14 13:00:59 +04:00

253 lines
9.6 KiB
Go

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: "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: "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)
}
})
}
}