feat(tui): event-derived ordering + retire Kotlin TUI
Rebuild the Go TUI transcript from the event stream instead of optimistic local echoes. ChatTurnEvent now maps to ServerMessage.ChatTurn (chat.turn); the TUI auto-vivifies sessions and renders user+router turns from events. SessionStarted is de-special-cased into SessionAnnounced (still carries workflowId), dropping the router.response shadow and optimistic echoes. Delete the unused Kotlin TUI (apps/tui) and add a kotlinx<->Go golden-fixture test to lock the wire protocol. Gitignore server runtime logs.
This commit is contained in:
@@ -237,6 +237,19 @@ func (m Model) session(id string) *Session {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureSession returns the session with id, creating an ACTIVE entry if absent.
|
||||
// Session existence is derived from the event stream (any event for an unknown
|
||||
// session vivifies it) rather than a dedicated control frame.
|
||||
func (m *Model) ensureSession(id string) *Session {
|
||||
if s := m.session(id); s != nil {
|
||||
return s
|
||||
}
|
||||
m.sessions = append(m.sessions, Session{
|
||||
ID: id, Status: "ACTIVE", LastEventAt: nowMillis(),
|
||||
})
|
||||
return &m.sessions[len(m.sessions)-1]
|
||||
}
|
||||
|
||||
// filteredSessions applies the workflow-id filter.
|
||||
func (m Model) filteredSessions() []Session {
|
||||
if m.filter == "" {
|
||||
|
||||
@@ -50,9 +50,24 @@ func inferCategory(t string) string {
|
||||
// applyServer mutates the model for a single (non-buffered) server message.
|
||||
func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
debugLog("SRV type=%s session=%s reason=%s", msg.Type, msg.SessionID, msg.Reason)
|
||||
// Auto-vivify: any event for an unknown session creates it, so session
|
||||
// existence is derived from the event stream, not a dedicated control frame.
|
||||
if msg.IsEventBearing() && msg.SessionID != "" {
|
||||
m.ensureSession(msg.SessionID)
|
||||
}
|
||||
switch msg.Type {
|
||||
case protocol.TypeSessionStarted:
|
||||
m.onSessionStarted(msg)
|
||||
case protocol.TypeSessionAnnounced:
|
||||
m.onSessionAnnounced(msg)
|
||||
case protocol.TypeChatTurn:
|
||||
m.routerConnected = true
|
||||
role := "router"
|
||||
if msg.Role == "USER" {
|
||||
role = "user"
|
||||
}
|
||||
m.appendRouter(msg.SessionID, RouterEntry{role, msg.Content})
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.LastEventAt = nowMillis()
|
||||
}
|
||||
case protocol.TypeSessionPaused:
|
||||
label := "PAUSED"
|
||||
if msg.Reason == "APPROVAL_PENDING" {
|
||||
@@ -183,9 +198,6 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
} else {
|
||||
m.providerType = "REMOTE"
|
||||
}
|
||||
case protocol.TypeRouterResponse:
|
||||
m.routerConnected = true
|
||||
m.appendRouter(msg.SessionID, RouterEntry{"router", msg.Content})
|
||||
case protocol.TypeWorkflowList:
|
||||
m.workflows = m.workflows[:0]
|
||||
for _, w := range msg.Workflows {
|
||||
@@ -227,30 +239,23 @@ func sessionIDOf(msg protocol.ServerMessage) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) onSessionStarted(msg protocol.ServerMessage) {
|
||||
hadOptimistic := false
|
||||
cleaned := m.sessions[:0:0]
|
||||
for _, s := range m.sessions {
|
||||
if s.Status == "STARTING" {
|
||||
hadOptimistic = true
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, s)
|
||||
}
|
||||
cleaned = append(cleaned, Session{
|
||||
ID: msg.SessionID, Status: "ACTIVE", WorkflowID: msg.WorkflowID,
|
||||
Name: msg.WorkflowID, LastEventAt: nowMillis(),
|
||||
})
|
||||
m.sessions = cleaned
|
||||
if hadOptimistic || m.selectedID == "" {
|
||||
m.selectedID = msg.SessionID
|
||||
// onSessionAnnounced fills in a session's workflow identity (the announce is the
|
||||
// only event carrying workflowId) and applies auto-focus. The session entry itself
|
||||
// was already created by the auto-vivify path in applyServer.
|
||||
func (m *Model) onSessionAnnounced(msg protocol.ServerMessage) {
|
||||
s := m.ensureSession(msg.SessionID)
|
||||
s.Status = "ACTIVE"
|
||||
if msg.WorkflowID != "" {
|
||||
s.WorkflowID = msg.WorkflowID
|
||||
s.Name = msg.WorkflowID
|
||||
}
|
||||
s.LastEventAt = nowMillis()
|
||||
if m.pendingWorkflowFocus {
|
||||
m.selectedID = msg.SessionID
|
||||
m.sessionEntered = true
|
||||
m.pendingWorkflowFocus = false
|
||||
} else {
|
||||
m.sessionEntered = true
|
||||
} else if m.selectedID == "" {
|
||||
m.selectedID = msg.SessionID
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -545,15 +545,14 @@ func (m Model) submit() (tea.Model, tea.Cmd) {
|
||||
if text == "" {
|
||||
return m, nil
|
||||
}
|
||||
// New optimistic chat session.
|
||||
// New chat session. The client owns the id, so create the entry locally and
|
||||
// focus it; the USER/ROUTER turns arrive as chat.turn events (no optimistic echo).
|
||||
id := newSessionID()
|
||||
m.sessions = append(m.sessions, Session{
|
||||
ID: id, Status: "STARTING", WorkflowID: "chat", Name: "chat",
|
||||
LastEventAt: nowMillis(),
|
||||
})
|
||||
s := m.ensureSession(id)
|
||||
s.WorkflowID = "chat"
|
||||
s.Name = "chat"
|
||||
m.selectedID = id
|
||||
m.sessionEntered = true
|
||||
m.appendRouter(id, RouterEntry{"user", text})
|
||||
m.client.Send(protocol.StartChatSession(id, text))
|
||||
m.clearInput()
|
||||
return m, nil
|
||||
@@ -569,7 +568,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) {
|
||||
hist = hist[len(hist)-50:]
|
||||
}
|
||||
m.history[sid] = hist
|
||||
m.appendRouter(sid, RouterEntry{"user", text})
|
||||
// No optimistic echo — the USER turn arrives as a chat.turn event.
|
||||
m.client.Send(protocol.ChatInput(sid, text, m.chatMode))
|
||||
m.clearInput()
|
||||
return m, nil
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package protocol
|
||||
|
||||
import "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)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package protocol implements the correx WebSocket wire contract used to talk
|
||||
// to apps/server over /stream. The server (Kotlin/kotlinx.serialization) uses a
|
||||
// "type" class discriminator. Server->client variants carry @SerialName values
|
||||
// (e.g. "session.started"); client->server variants have no @SerialName, so the
|
||||
// (e.g. "session.announced"); client->server variants have no @SerialName, so the
|
||||
// discriminator is the fully-qualified Kotlin class name.
|
||||
package protocol
|
||||
|
||||
@@ -13,7 +13,8 @@ const clientPrefix = "com.correx.apps.server.protocol.ClientMessage."
|
||||
|
||||
// Server message discriminator values (@SerialName on ServerMessage variants).
|
||||
const (
|
||||
TypeSessionStarted = "session.started"
|
||||
TypeSessionAnnounced = "session.announced"
|
||||
TypeChatTurn = "chat.turn"
|
||||
TypeSessionPaused = "session.paused"
|
||||
TypeSessionResumed = "session.resumed"
|
||||
TypeSessionCompleted = "session.completed"
|
||||
@@ -54,6 +55,8 @@ type ServerMessage struct {
|
||||
WorkflowID string `json:"workflowId"`
|
||||
StageID string `json:"stageId"`
|
||||
ToolName string `json:"toolName"`
|
||||
Role string `json:"role"`
|
||||
TurnID string `json:"turnId"`
|
||||
Reason string `json:"reason"`
|
||||
Summary string `json:"outputSummary"`
|
||||
Response string `json:"responseText"`
|
||||
@@ -168,7 +171,7 @@ func Decode(raw []byte) (ServerMessage, error) {
|
||||
// and are applied immediately.
|
||||
func (m ServerMessage) IsEventBearing() bool {
|
||||
switch m.Type {
|
||||
case TypeSessionStarted, TypeSessionPaused, TypeSessionResumed,
|
||||
case TypeSessionAnnounced, TypeChatTurn, TypeSessionPaused, TypeSessionResumed,
|
||||
TypeSessionCompleted, TypeSessionFailed,
|
||||
TypeStageStarted, TypeStageCompleted, TypeStageFailed,
|
||||
TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout,
|
||||
|
||||
Reference in New Issue
Block a user