622b331de3
Wires the workspace handshake end to end so a session's workspace is bound
from the client's cwd at connect time and is event-sourced for replay.
- Go TUI sends Hello{workingDir=os.Getwd()} as the first WS frame on connect
(protocol.go encoder + client.go connect path; golden test pins the wire
format against the Kotlin discriminator).
- Server adds ClientMessage.Hello, stashes the per-connection workingDir, and
on session start resolves it through WorkspaceResolver's trust pipeline,
emits SessionWorkspaceBoundEvent (invariant #9), and threads the resolved
workspace into OrchestrationConfig for the live run. A Hello after the first
StartSession is ignored (warn); a rejected path binds the resolver fallback.
- Replay derives the workspace from the recorded event: SessionState gains
boundWorkspace, DefaultSessionReducer fills it from SessionWorkspaceBoundEvent,
and ReplayOrchestrator uses it (Path.of only, no filesystem re-query —
invariant #8) with graceful fallback to config for pre-Phase-B logs.
Absent Hello / null resolver degrades to the prior config-workspace behavior.
158 lines
5.3 KiB
Go
158 lines
5.3 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: "snapshot_complete (non-event control frame)",
|
|
json: `{"type":"snapshot_complete"}`,
|
|
wantType: TypeSnapshotComplete,
|
|
eventBear: false,
|
|
},
|
|
}
|
|
|
|
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"])
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|