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:
2026-06-02 23:38:20 +04:00
parent b2f60d09bb
commit 1017bfffef
60 changed files with 231 additions and 4813 deletions
+13
View File
@@ -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 == "" {
+29 -24
View File
@@ -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
}
}
+6 -7
View File
@@ -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