From 4f6bfa85f7650837ed9bff3e47c0bf710b422a19 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 13 Jun 2026 10:39:52 +0400 Subject: [PATCH] =?UTF-8?q?feat(server,tui):=20session=20stats=20pane=20?= =?UTF-8?q?=E2=80=94=20metrics=20over=20the=20WS=20bus=20+=20Bubble=20Tea?= =?UTF-8?q?=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the observability metrics (observability-spec §3-tier-2) in the Go TUI: a session-summary pane showing duration, token throughput per provider, tool time, approval latency per tier, failure counts, and the session wall-time accounting split. Press `S` (or palette → "session stats"). Mirrors the artifacts/config fetch pattern exactly — a WS request/response, not a new transport. Server-side reuses the already-tested MetricsInspectionService (one MetricsReport definition, two wires: REST `correx stats` + WS). Server: - ClientMessage.GetSessionStats + ServerMessage.SessionStats(@SerialName session.stats), reusing MetricsReport as the nested payload. - StreamQueries.sessionStats replays via MetricsInspectionService (pure read, replay-neutral); routed in GlobalStreamHandler. - ServerMessageSerializationTest golden pins the session.stats wire format. TUI (Go/Bubble Tea): - protocol: TypeSessionStats + StatsDto/nested structs + GetSessionStats encoder + golden decode test (cross-language contract). - OverlayStats pane (statsModal), `S` key + palette entry + footer hint, loading/cache-by-session state, bounded breakdown rows. - demo `stats` preview case for serverless visual verification. --- .../apps/server/protocol/ClientMessage.kt | 4 + .../apps/server/protocol/ServerMessage.kt | 15 +++ .../apps/server/ws/GlobalStreamHandler.kt | 1 + .../correx/apps/server/ws/StreamQueries.kt | 13 ++ .../ServerMessageSerializationTest.kt | 47 ++++++++ .../internal/app/config_overlay_test.go | 2 +- apps/tui-go/internal/app/demo.go | 49 ++++++++ apps/tui-go/internal/app/model.go | 20 ++-- apps/tui-go/internal/app/overlays.go | 113 ++++++++++++++++++ apps/tui-go/internal/app/server.go | 6 +- apps/tui-go/internal/app/update.go | 24 ++++ apps/tui-go/internal/app/view.go | 2 +- apps/tui-go/internal/protocol/golden_test.go | 33 +++++ apps/tui-go/internal/protocol/protocol.go | 68 +++++++++++ 14 files changed, 387 insertions(+), 10 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt index b6270c81..7aba33e5 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt @@ -42,6 +42,10 @@ sealed class ClientMessage { @Serializable data class ListArtifacts(val sessionId: SessionId) : ClientMessage() + /** Operator request for a session's derived metrics (replied to with a SessionStats). */ + @Serializable + data class GetSessionStats(val sessionId: SessionId) : ClientMessage() + /** Operator request for the current editable config (replied to with a ConfigSnapshot). */ @Serializable data object GetConfig : ClientMessage() diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt index 3d0f5197..9a9b8aed 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt @@ -1,5 +1,6 @@ package com.correx.apps.server.protocol +import com.correx.apps.server.metrics.MetricsReport import com.correx.core.approvals.Tier import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ArtifactId @@ -425,6 +426,20 @@ sealed interface ServerMessage { override val sessionSequence: Long? = null, ) : ServerMessage, NonEventMessage + /** + * A session's derived metrics, returned in response to a GetSessionStats request. Not + * event-derived (it is a projection-replay snapshot), so cursors are null. [stats] is the same + * [MetricsReport] the REST `/sessions/{id}/metrics` endpoint serves — one definition, two wires. + */ + @Serializable + @SerialName("session.stats") + data class SessionStats( + val sessionId: SessionId, + val stats: MetricsReport, + override val sequence: Long? = null, + override val sessionSequence: Long? = null, + ) : ServerMessage, NonEventMessage + /** * The current editable config (reply to GetConfig / UpdateConfig). [restartRequired] lists the * keys in a just-applied patch that need a server restart to take effect; [error] is set (and diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index 49ee0800..521f9397 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -229,6 +229,7 @@ class GlobalStreamHandler(private val module: ServerModule) { .onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) } } is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId)) + is ClientMessage.GetSessionStats -> sendFrame(queries.sessionStats(msg.sessionId)) is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList())) is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch)) is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported")) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt index cb2fa0c4..bc2bd1c2 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt @@ -2,6 +2,7 @@ package com.correx.apps.server.ws import com.correx.apps.server.ServerModule import com.correx.apps.server.config.ConfigUpdateResult +import com.correx.apps.server.metrics.MetricsInspectionService import com.correx.apps.server.protocol.ArtifactSummaryDto import com.correx.apps.server.protocol.ConfigFieldDto import com.correx.apps.server.protocol.ServerMessage @@ -33,6 +34,18 @@ private val lenientJson = Json { ignoreUnknownKeys = true; isLenient = true } */ class StreamQueries(private val module: ServerModule) { + private val metricsService = MetricsInspectionService(module.eventStore) + + /** + * A session's derived metrics as a snapshot frame. Replays the event log through + * [MetricsInspectionService] (the same projection the REST metrics endpoint uses) — pure read, + * no events appended, so it is replay-neutral (invariant #1/#8). + */ + suspend fun sessionStats(sessionId: SessionId): ServerMessage.SessionStats { + val report = withContext(Dispatchers.IO) { metricsService.inspect(sessionId) } + return ServerMessage.SessionStats(sessionId = sessionId, stats = report) + } + /** * Reads a session's events to assemble its artifact listing (creation order preserved), * resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read — diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt index 07def03f..d0041aea 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt @@ -1,5 +1,10 @@ package com.correx.apps.server.protocol +import com.correx.apps.server.metrics.FailureMetrics +import com.correx.apps.server.metrics.MetricsReport +import com.correx.apps.server.metrics.ProviderStatsDto +import com.correx.apps.server.metrics.TierApprovalStatsDto +import com.correx.apps.server.metrics.ToolStatsDto import com.correx.core.events.types.SessionId import kotlinx.serialization.json.Json import org.junit.jupiter.api.Assertions.assertEquals @@ -146,6 +151,48 @@ class ServerMessageSerializationTest { assertEquals(listOf("server.port"), decoded.restartRequired) } + @Test + fun `SessionStats encodes type, sessionId and nested metrics`() { + val msg = ServerMessage.SessionStats( + sessionId = SessionId("sess-stats"), + stats = MetricsReport( + sessionId = "sess-stats", + eventCount = 12, + sessionDurationMs = 60_000, + inferenceCount = 3, + inferenceMs = 6000, + promptTokens = 310, + completionTokens = 170, + tokensPerSecond = 28.3, + perProvider = listOf(ProviderStatsDto("lfm:a", 2, 5000, 300, 150, 30.0)), + toolCount = 3, + toolMs = 1500, + perTool = listOf(ToolStatsDto("shell.read", 2, 1200)), + approvalsRequested = 2, + approvalsResolved = 1, + approvalsPending = 1, + approvalWaitMs = 30_000, + avgApprovalWaitMs = 30_000, + perTier = listOf(TierApprovalStatsDto("T2", 2, 1, 30_000, 30_000)), + failures = FailureMetrics(inferenceFailures = 1, toolFailures = 1, workflowFailures = 1), + inferencePct = 10.0, + toolPct = 2.5, + approvalWaitPct = 50.0, + ), + ) + val jsonStr = ProtocolSerializer.encodeServerMessage(msg) + assert(jsonStr.contains("\"type\":\"session.stats\"")) { "expected type=session.stats" } + assert(jsonStr.contains("\"sessionId\":\"sess-stats\"")) { "expected sessionId" } + assert(jsonStr.contains("\"completionTokens\":170")) { "expected nested metric field" } + assert(jsonStr.contains("\"provider\":\"lfm:a\"")) { "expected per-provider row" } + + val decoded = json.decodeFromString(jsonStr) + assertEquals("sess-stats", decoded.stats.sessionId) + assertEquals(3, decoded.stats.inferenceCount) + assertEquals(1, decoded.stats.perProvider.size) + assertEquals(50.0, decoded.stats.approvalWaitPct) + } + @Test fun `SessionAnnounced round-trips through JSON`() { val original = ServerMessage.SessionAnnounced( diff --git a/apps/tui-go/internal/app/config_overlay_test.go b/apps/tui-go/internal/app/config_overlay_test.go index 1984dcb9..4d0ccd4e 100644 --- a/apps/tui-go/internal/app/config_overlay_test.go +++ b/apps/tui-go/internal/app/config_overlay_test.go @@ -76,7 +76,7 @@ func TestConfigSnapshotClearsStagedOnCleanReply(t *testing.T) { m := configModel() m.configStaged["project.enabled"] = "true" m.applyServer(protocol.ServerMessage{ - Type: protocol.TypeConfigSnapshot, + Type: protocol.TypeConfigSnapshot, ConfigFields: []protocol.ConfigFieldDto{{Key: "project.enabled", Type: "BOOL", Value: "true"}}, }) if len(m.configStaged) != 0 { diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index e0de95ae..9bbd0cd0 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -1,5 +1,7 @@ package app +import "github.com/correx/tui-go/internal/protocol" + // PreviewFrame renders a single static frame for a named UI state at the given // terminal size. Used by cmd/preview to screenshot the look without a live // server. Not part of the runtime path. @@ -138,10 +140,57 @@ func PreviewFrame(kind string, w, h int) string { m.selectedID = "04a546aa" m.overlay = OverlayPalette m.paletteFilter = "to" + + case "stats": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.overlay = OverlayStats + m.statsFor = "04a546aa" + m.stats = sampleStats() } return m.View() } +func sampleStats() *protocol.StatsDto { + return &protocol.StatsDto{ + SessionID: "04a546aa", + EventCount: 28, + SessionDurationMs: 204_000, + InferenceCount: 5, + InferenceMs: 38_400, + PromptTokens: 4120, + CompletionTokens: 1860, + TokensPerSecond: 48.4, + PerProvider: []protocol.ProviderStatsDto{ + {Provider: "llama-cpp:qwen2.5-coder-14b", CompletedCount: 4, TotalLatencyMs: 33_200, PromptTokens: 3800, CompletionTokens: 1700, TokensPerSecond: 51.2}, + {Provider: "llama-cpp:default", CompletedCount: 1, TotalLatencyMs: 5200, PromptTokens: 320, CompletionTokens: 160, TokensPerSecond: 30.8}, + }, + ToolCount: 6, + ToolMs: 4300, + PerTool: []protocol.ToolStatsDto{ + {ToolName: "file_write", CompletedCount: 2, TotalDurationMs: 2600}, + {ToolName: "read_file", CompletedCount: 3, TotalDurationMs: 1200}, + {ToolName: "shell_exec", CompletedCount: 1, TotalDurationMs: 500}, + }, + ApprovalsRequested: 2, + ApprovalsResolved: 2, + ApprovalsPending: 0, + ApprovalWaitMs: 45_000, + AvgApprovalWaitMs: 22_500, + PerTier: []protocol.TierApprovalStatsDto{ + {Tier: "T2", RequestedCount: 1, ResolvedCount: 1, TotalWaitMs: 15_000, AvgWaitMs: 15_000}, + {Tier: "T3", RequestedCount: 1, ResolvedCount: 1, TotalWaitMs: 30_000, AvgWaitMs: 30_000}, + }, + Failures: protocol.FailureMetricsDto{ToolFailures: 1}, + InferencePct: 18.8, + ToolPct: 2.1, + ApprovalWaitPct: 22.1, + } +} + func sampleWorkflows() []Workflow { return []Workflow{ {ID: "healthcheck", Description: "ping endpoints and report status"}, diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 883913b0..b84053de 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -49,6 +49,7 @@ const ( OverlayModels OverlayArtifacts OverlayConfig + OverlayStats ) // RouterEntry is one line in a session's conversation transcript. @@ -143,15 +144,15 @@ type Model struct { reconnecting bool // sessions - sessions []Session - selectedID string - filter string - workflows []Workflow - wfIndex int // -1 = not in workflow picker - wfVisible bool + sessions []Session + selectedID string + filter string + workflows []Workflow + wfIndex int // -1 = not in workflow picker + wfVisible bool wfPendingID string // workflow chosen, awaiting an intent line before StartSession wfPendingName string - bgUpdates int + bgUpdates int // input editMode EditMode @@ -209,6 +210,11 @@ type Model struct { configRestart []string // keys from the last save that need a restart configLoading bool + // session stats (OverlayStats) — populated by the session.stats reply + stats *protocol.StatsDto + statsFor string // sessionId the current stats belong to + statsLoading bool + // command palette paletteFilter string paletteIndex int diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 46b40b20..7e68903d 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -1,6 +1,7 @@ package app import ( + "fmt" "sort" "strings" @@ -43,6 +44,8 @@ func (m Model) renderOverlay(base string) string { return m.center(m.artifactsModal()) case OverlayConfig: return m.center(m.configModal()) + case OverlayStats: + return m.center(m.statsModal()) } return base } @@ -545,6 +548,116 @@ func (m Model) artifactsModal() string { return t.Overlay.Width(w).Render(b.String()) } +// statsBreakdownRows caps how many per-provider / per-tool / per-tier rows the pane +// shows; the server already sorts each list by descending cost, so the cap keeps the +// heaviest contributors and the modal bounded. +const statsBreakdownRows = 6 + +// humanDurMs renders a millisecond span as a compact "1h 2m 3s" string. +func humanDurMs(ms int64) string { + if ms <= 0 { + return "0s" + } + total := ms / 1000 + h := total / 3600 + mn := (total / 60) % 60 + s := total % 60 + out := "" + if h > 0 { + out += itoa(int(h)) + "h " + } + if h > 0 || mn > 0 { + out += itoa(int(mn)) + "m " + } + return out + itoa(int(s)) + "s" +} + +func (m Model) statsModal() string { + t := m.theme + w := m.modalWidth() + + var b strings.Builder + b.WriteString(m.titleLine("session stats")) + if m.selectedID != "" { + b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint)) + } + b.WriteString("\n\n") + + if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID { + msg := "loading…" + if !m.statsLoading && m.stats == nil { + msg = "no stats for this session" + } + b.WriteString(mbg(t, " "+msg, t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + + s := m.stats + section := func(label string) string { + return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label) + } + line := func(s string) string { return mbg(t, s, t.P.Fg) } + faint := func(s string) string { return mbg(t, s, t.P.Faint) } + + // header row: duration + event count + b.WriteString(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) + + faint(" events ") + line(itoa(int(s.EventCount))) + "\n\n") + + // inference + b.WriteString(section("Inference") + "\n") + b.WriteString(line(fmt.Sprintf(" %d calls · %d prompt + %d completion = %d tok · %.1f tok/s", + s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.PromptTokens+s.CompletionTokens, s.TokensPerSecond)) + "\n") + for i, p := range s.PerProvider { + if i >= statsBreakdownRows { + b.WriteString(faint(fmt.Sprintf(" … +%d more", len(s.PerProvider)-statsBreakdownRows)) + "\n") + break + } + b.WriteString(faint(fmt.Sprintf(" %-20s %3d calls %6d tok %7d ms %.1f tok/s", + padRaw(p.Provider, 20), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TotalLatencyMs, p.TokensPerSecond)) + "\n") + } + b.WriteString("\n") + + // tools + b.WriteString(section("Tools") + "\n") + b.WriteString(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs)) + "\n") + for i, tl := range s.PerTool { + if i >= statsBreakdownRows { + b.WriteString(faint(fmt.Sprintf(" … +%d more", len(s.PerTool)-statsBreakdownRows)) + "\n") + break + } + b.WriteString(faint(fmt.Sprintf(" %-24s %3d %7d ms", padRaw(tl.ToolName, 24), tl.CompletedCount, tl.TotalDurationMs)) + "\n") + } + b.WriteString("\n") + + // approvals + b.WriteString(section("Approvals") + "\n") + b.WriteString(line(fmt.Sprintf(" %d req · %d resolved · %d pending · avg wait %d ms", + s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs)) + "\n") + for _, tr := range s.PerTier { + b.WriteString(faint(fmt.Sprintf(" %-4s req %d res %d avg %d ms", tr.Tier, tr.RequestedCount, tr.ResolvedCount, tr.AvgWaitMs)) + "\n") + } + b.WriteString("\n") + + // failures + f := s.Failures + b.WriteString(section("Failures") + "\n") + b.WriteString(line(fmt.Sprintf(" inference %d · timeouts %d · tool %d · rejected %d · stage %d · workflow %d", + f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures)) + "\n\n") + + // time accounting + other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct + if other < 0 { + other = 0 + } + b.WriteString(section("Time") + " " + + line(fmt.Sprintf("inference %.1f%% · tools %.1f%% · approval-wait %.1f%% · other %.1f%%", + s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other)) + "\n") + + b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) +} + // shortID trims a long artifact id for the list column while staying identifiable. func shortID(id string) string { if len(id) <= 14 { diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 8030fd10..28d11916 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -304,6 +304,10 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { if m.artifactsIndex >= len(m.artifacts) { m.artifactsIndex = 0 } + case protocol.TypeSessionStats: + m.stats = msg.Stats + m.statsFor = msg.SessionID + m.statsLoading = false case protocol.TypeConfigSnapshot: m.configFields = msg.ConfigFields m.configRestart = msg.ConfigRestartRequired @@ -332,7 +336,7 @@ func sessionIDOf(msg protocol.ServerMessage) string { protocol.TypeProtocolError, protocol.TypeProviderStatus, protocol.TypeWorkflowList, protocol.TypeRouterResponse, protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus, - protocol.TypeArtifactList, protocol.TypeConfigSnapshot: + protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats: return "" default: return msg.SessionID diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 59353c9b..44bbc523 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -169,6 +169,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.overlay = OverlayToolPalette case "v": m.openArtifacts() + case "S": + m.openStats() case "g": m.openConfig() case "m": @@ -429,6 +431,10 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case runeIs(k, "m"): m.overlay = OverlayNone } + case OverlayStats: + if runeIs(k, "S") { + m.overlay = OverlayNone + } } return m, nil } @@ -450,6 +456,21 @@ func (m *Model) openArtifacts() { m.client.Send(protocol.ListArtifacts(m.selectedID)) } +// openStats opens the session-stats pane for the selected session and requests its +// metrics from the server. No-op when no session is selected. +func (m *Model) openStats() { + if m.selectedID == "" { + return + } + m.overlay = OverlayStats + // Reuse a cached report only if it's for this session; otherwise show a loading state. + if m.statsFor != m.selectedID { + m.stats = nil + m.statsLoading = true + } + m.client.Send(protocol.GetSessionStats(m.selectedID)) +} + // openModelsOverlay opens the model picker, pre-selecting the resident model. func (m *Model) openModelsOverlay() { m.overlay = OverlayModels @@ -502,6 +523,7 @@ func paletteCommands() []paletteCmd { {"models", "swap model", "pick / pin the local model"}, {"events", "event inspector", "browse the event stream"}, {"artifacts", "view artifacts", "browse this session's artifacts"}, + {"stats", "session stats", "metrics for the selected session"}, {"config", "edit config", "view / change correx settings"}, {"mode", "toggle mode", "switch chat / steering"}, {"cancel", "cancel session", "stop the selected session"}, @@ -539,6 +561,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { m.overlayEventIdx = 0 case "artifacts": m.openArtifacts() + case "stats": + m.openStats() case "config": m.openConfig() case "mode": diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index e0eee513..b13df5ed 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -155,7 +155,7 @@ func (m Model) renderFooter() string { case m.displayState() == StateIdle: hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")} default: // StateInSession - hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} if s := m.session(m.selectedID); s != nil && s.Pending != nil { hints = append(hints, hint("a", "approval (pending)")) } diff --git a/apps/tui-go/internal/protocol/golden_test.go b/apps/tui-go/internal/protocol/golden_test.go index c6bb5568..6f914934 100644 --- a/apps/tui-go/internal/protocol/golden_test.go +++ b/apps/tui-go/internal/protocol/golden_test.go @@ -116,6 +116,39 @@ func TestDecodeGoldenServerFrames(t *testing.T) { 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) + } + }, + }, } for _, c := range cases { diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index 5a05cf89..b2a5c6b0 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -51,6 +51,7 @@ const ( TypeRouterNarration = "router.narration" TypeArtifactList = "artifact.list" TypeConfigSnapshot = "config.snapshot" + TypeSessionStats = "session.stats" ) // ServerMessage is a flat decode of every server->client variant. Field names @@ -131,6 +132,68 @@ type ServerMessage struct { ConfigFields []ConfigFieldDto `json:"fields"` ConfigRestartRequired []string `json:"restartRequired"` ConfigError *string `json:"error"` + + // session.stats — derived metrics for a session (reply to GetSessionStats) + Stats *StatsDto `json:"stats"` +} + +// StatsDto mirrors the server's MetricsReport: a session's derived metrics +// (observability-spec §2/§3). Raw sums plus the derived ratios the pane renders. +type StatsDto struct { + SessionID string `json:"sessionId"` + EventCount int64 `json:"eventCount"` + SessionDurationMs int64 `json:"sessionDurationMs"` + InferenceCount int64 `json:"inferenceCount"` + InferenceMs int64 `json:"inferenceMs"` + PromptTokens int64 `json:"promptTokens"` + CompletionTokens int64 `json:"completionTokens"` + TokensPerSecond float64 `json:"tokensPerSecond"` + PerProvider []ProviderStatsDto `json:"perProvider"` + ToolCount int64 `json:"toolCount"` + ToolMs int64 `json:"toolMs"` + PerTool []ToolStatsDto `json:"perTool"` + ApprovalsRequested int64 `json:"approvalsRequested"` + ApprovalsResolved int64 `json:"approvalsResolved"` + ApprovalsPending int64 `json:"approvalsPending"` + ApprovalWaitMs int64 `json:"approvalWaitMs"` + AvgApprovalWaitMs int64 `json:"avgApprovalWaitMs"` + PerTier []TierApprovalStatsDto `json:"perTier"` + Failures FailureMetricsDto `json:"failures"` + InferencePct float64 `json:"inferencePct"` + ToolPct float64 `json:"toolPct"` + ApprovalWaitPct float64 `json:"approvalWaitPct"` +} + +type ProviderStatsDto struct { + Provider string `json:"provider"` + CompletedCount int64 `json:"completedCount"` + TotalLatencyMs int64 `json:"totalLatencyMs"` + PromptTokens int64 `json:"promptTokens"` + CompletionTokens int64 `json:"completionTokens"` + TokensPerSecond float64 `json:"tokensPerSecond"` +} + +type ToolStatsDto struct { + ToolName string `json:"toolName"` + CompletedCount int64 `json:"completedCount"` + TotalDurationMs int64 `json:"totalDurationMs"` +} + +type TierApprovalStatsDto struct { + Tier string `json:"tier"` + RequestedCount int64 `json:"requestedCount"` + ResolvedCount int64 `json:"resolvedCount"` + TotalWaitMs int64 `json:"totalWaitMs"` + AvgWaitMs int64 `json:"avgWaitMs"` +} + +type FailureMetricsDto struct { + InferenceFailures int64 `json:"inferenceFailures"` + InferenceTimeouts int64 `json:"inferenceTimeouts"` + ToolFailures int64 `json:"toolFailures"` + ToolRejections int64 `json:"toolRejections"` + StageFailures int64 `json:"stageFailures"` + WorkflowFailures int64 `json:"workflowFailures"` } type ConfigFieldDto struct { @@ -275,6 +338,11 @@ func ListArtifacts(sessionID string) []byte { return encode("ListArtifacts", map[string]any{"sessionId": sessionID}) } +// GetSessionStats asks the server for a session's derived metrics (replied to with session.stats). +func GetSessionStats(sessionID string) []byte { + return encode("GetSessionStats", map[string]any{"sessionId": sessionID}) +} + // GetConfig requests the current editable config (replied to with a config.snapshot). func GetConfig() []byte { return encode("GetConfig", map[string]any{})