Files
correx/apps/tui-go/internal/app/server.go
T
kami 3600ec6897 feat(tui): surface plane-2 rationale in the approval band
The plane-2 tool-call assessor records verified preconditions
("[PATH_OUTSIDE_WORKSPACE] …") and the server already ships them in
RiskSummaryDto.rationale, but the Go TUI's RiskSummaryDto had no
Rationale field — so the justification was silently dropped at decode
and the gate showed only an opaque tier.

Decode the rationale, carry it on Approval, and render it under the
header in the approval band (warn-marked, above the diff). This closes
the last deferred item of the plane-2 slice-1 plan: the assessment
surfaced to the approval UX. Golden test pins the rationale decode.
2026-06-03 00:30:26 +04:00

375 lines
10 KiB
Go

package app
import (
"crypto/rand"
"encoding/hex"
"strings"
"time"
"github.com/correx/tui-go/internal/protocol"
)
func nowMillis() int64 { return time.Now().UnixMilli() }
func newSessionID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func containsFold(haystack, needle string) bool {
return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
}
func formatTime(epochMillis int64) string {
return time.UnixMilli(epochMillis).UTC().Format("15:04:05")
}
// inferCategory maps an event type string to a display category, matching the
// Kotlin inferCategory heuristic.
func inferCategory(t string) string {
lt := strings.ToLower(t)
switch {
case strings.Contains(lt, "approval"):
return "Approval"
case strings.Contains(lt, "infer"):
return "Inference"
case strings.Contains(lt, "tool"):
return "Tool"
case strings.Contains(lt, "context"):
return "Context"
case strings.Contains(lt, "stage"), strings.Contains(lt, "lifecycle"):
return "Lifecycle"
case strings.Contains(lt, "session"), strings.Contains(lt, "artifact"):
return "Domain"
default:
return "Lifecycle"
}
}
// 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.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" {
label = "PAUSED awaiting approval"
}
m.touch(msg.SessionID, label)
if s := m.session(msg.SessionID); s != nil {
s.Active = false
}
case protocol.TypeSessionResumed:
if s := m.session(msg.SessionID); s != nil {
s.Status = "ACTIVE"
s.Pending = nil
s.LastEventAt = nowMillis()
}
case protocol.TypeSessionCompleted:
m.touch(msg.SessionID, "COMPLETED")
if s := m.session(msg.SessionID); s != nil {
s.Active = false
}
case protocol.TypeSessionFailed:
m.touch(msg.SessionID, "FAILED")
if s := m.session(msg.SessionID); s != nil {
s.Active = false
}
if msg.SessionID == m.selectedID {
m.selectedID = ""
}
case protocol.TypeStageStarted:
if s := m.session(msg.SessionID); s != nil {
s.CurrentStage = msg.StageID
s.Tools = nil
s.addEvent(msg.OccurredAt, "StageStarted", msg.StageID)
}
case protocol.TypeStageCompleted:
if s := m.session(msg.SessionID); s != nil {
s.CurrentStage = ""
s.addEvent(msg.OccurredAt, "StageCompleted", msg.StageID)
}
case protocol.TypeStageFailed:
if s := m.session(msg.SessionID); s != nil {
s.CurrentStage = ""
s.addEvent(msg.OccurredAt, "StageFailed", msg.StageID)
}
case protocol.TypeInferenceStarted:
if s := m.session(msg.SessionID); s != nil {
s.Active = true
s.addEvent(nowMillis(), "InferenceStarted", msg.StageID)
}
case protocol.TypeInferenceDone:
if s := m.session(msg.SessionID); s != nil {
s.Active = false
s.LastOutput = msg.Summary
if msg.Response != "" {
s.LastResponse = msg.Response
}
s.addEvent(msg.OccurredAt, "InferenceCompleted", msg.StageID)
}
case protocol.TypeInferenceTimeout:
if s := m.session(msg.SessionID); s != nil {
s.Active = false
s.addEvent(nowMillis(), "InferenceTimedOut", msg.StageID)
}
case protocol.TypeToolStarted:
if s := m.session(msg.SessionID); s != nil {
s.Active = true
s.Tools = append(s.Tools, ToolRecord{Name: msg.ToolName, Status: ToolStarted})
if len(s.Tools) > 8 {
s.Tools = s.Tools[len(s.Tools)-8:]
}
s.LastEventAt = nowMillis()
}
case protocol.TypeToolCompleted:
if s := m.session(msg.SessionID); s != nil {
s.Active = false
s.markTool(msg.ToolName, ToolCompleted)
s.LastOutput = msg.ToolName + ": " + msg.Summary
s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName)
}
if msg.Diff != nil && *msg.Diff != "" {
m.appendRouter(msg.SessionID, RouterEntry{"tool", *msg.Diff})
}
case protocol.TypeToolAssessed:
if s := m.session(msg.SessionID); s != nil {
if msg.Disposition != "PROCEED" || len(msg.AssessedIssues) > 0 {
detail := msg.Disposition
if len(msg.AssessedIssues) > 0 {
detail += " [" + msg.AssessedIssues[0].Code + "]"
}
s.LastOutput = msg.ToolName + " assessed: " + detail
s.addEvent(msg.OccurredAt, "ToolAssessed", detail)
}
}
case protocol.TypeToolFailed:
if s := m.session(msg.SessionID); s != nil {
s.markTool(msg.ToolName, ToolFailed)
s.LastEventAt = msg.OccurredAt
}
case protocol.TypeToolRejected:
if s := m.session(msg.SessionID); s != nil {
s.markTool(msg.ToolName, ToolRejected)
s.LastEventAt = nowMillis()
}
case protocol.TypeArtifactCreated:
if s := m.session(msg.SessionID); s != nil {
s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID)
}
case protocol.TypeApprovalRequired:
m.onApprovalRequired(msg)
case protocol.TypeSessionSnapshot:
m.onSnapshot(msg)
case protocol.TypeStageManifest:
if s := m.session(msg.SessionID); s != nil {
byStage := map[string][]ManifestTool{}
for _, st := range msg.Stages {
tools := make([]ManifestTool, 0, len(st.Tools))
for _, td := range st.Tools {
tools = append(tools, ManifestTool{Name: td.Name, Tier: td.Tier})
}
byStage[st.StageID] = tools
}
s.ToolsByStage = byStage
}
case protocol.TypeProviderStatus:
m.currentModel = msg.ProviderID
if containsFold(msg.ProviderID, "llama") || containsFold(msg.ProviderID, "local") {
m.providerType = "LOCAL"
} else {
m.providerType = "REMOTE"
}
case protocol.TypeWorkflowList:
m.workflows = m.workflows[:0]
for _, w := range msg.Workflows {
m.workflows = append(m.workflows, Workflow{ID: w.WorkflowID, Description: w.Description})
}
case protocol.TypeModelChanged:
if msg.Loaded {
m.currentModel = msg.ModelID
m.providerType = "LOCAL"
}
case protocol.TypeModelList:
m.availableModels = append(m.availableModels[:0], msg.Models...)
if msg.Current != "" {
m.currentModel = msg.Current
m.providerType = "LOCAL"
}
case protocol.TypeResourceStatus:
m.gpuUsedMB = msg.GpuMemoryUsedMb
m.gpuTotalMB = msg.GpuMemoryTotalMb
m.gpuUtil = msg.GpuUtilizationPct
m.ramMB = msg.ProcessRssMb
}
// Background-update badge for non-selected sessions.
if sid := sessionIDOf(msg); sid != "" && sid != m.selectedID {
m.bgUpdates++
}
}
func sessionIDOf(msg protocol.ServerMessage) string {
switch msg.Type {
case protocol.TypeStageManifest, protocol.TypeSnapshotComplete,
protocol.TypeProtocolError, protocol.TypeProviderStatus,
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus:
return ""
default:
return 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 if m.selectedID == "" {
m.selectedID = msg.SessionID
}
}
func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
risk := "unknown"
var rationale []string
if msg.RiskSummary != nil {
risk = msg.RiskSummary.Level
rationale = msg.RiskSummary.Rationale
}
info := &Approval{
RequestID: msg.RequestID,
SessionID: msg.SessionID,
Tier: msg.Tier,
Risk: risk,
ToolName: deref(&msg.ToolName),
Preview: derefp(msg.Preview),
Rationale: rationale,
}
if s := m.session(msg.SessionID); s != nil {
s.Pending = info
}
}
func (m *Model) onSnapshot(msg protocol.ServerMessage) {
var pending *Approval
if len(msg.PendingAppr) > 0 {
a := msg.PendingAppr[0]
pending = &Approval{
RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier,
Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview),
}
}
status := "running"
if msg.State != nil {
status = msg.State.Status
}
if pending != nil {
status = "PAUSED awaiting approval"
}
sess := Session{
ID: msg.SessionID, Status: status, WorkflowID: msg.WorkflowID,
Name: msg.WorkflowID, LastEventAt: nowMillis(), Pending: pending,
}
if msg.State != nil && msg.State.CurrentStageID != nil {
sess.CurrentStage = *msg.State.CurrentStageID
}
for _, e := range msg.RecentEvents {
sess.Events = append(sess.Events, EventEntry{formatTime(e.Timestamp), e.Type, e.Detail})
}
for _, t := range msg.Tools {
sess.Tools = append(sess.Tools, ToolRecord{Name: t.Name, Tier: t.Tier, Status: toolStatusOf(t.Status)})
}
// Replace existing session with same id, else append.
replaced := false
for i := range m.sessions {
if m.sessions[i].ID == msg.SessionID {
m.sessions[i] = sess
replaced = true
break
}
}
if !replaced {
m.sessions = append(m.sessions, sess)
}
if m.selectedID == "" {
m.selectedID = msg.SessionID
}
}
func (m *Model) touch(id, status string) {
if s := m.session(id); s != nil {
s.Status = status
s.LastEventAt = nowMillis()
}
}
// --- Session helpers ---
func (s *Session) addEvent(epochMillis int64, typ, detail string) {
s.Events = append(s.Events, EventEntry{formatTime(epochMillis), typ, detail})
if len(s.Events) > 7 {
s.Events = s.Events[len(s.Events)-7:]
}
s.LastEventAt = epochMillis
}
func (s *Session) markTool(name string, status ToolStatus) {
for i := range s.Tools {
if s.Tools[i].Name == name && s.Tools[i].Status == ToolStarted {
s.Tools[i].Status = status
}
}
}
func toolStatusOf(s string) ToolStatus {
switch s {
case "COMPLETED":
return ToolCompleted
case "FAILED":
return ToolFailed
case "REJECTED":
return ToolRejected
default:
return ToolStarted
}
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
func derefp(s *string) string { return deref(s) }