diff --git a/AGENTS.md b/AGENTS.md index a113ba9..e14eae4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,10 @@ feat(tui): compact tool steps with Ctrl+E details toggle ## Workflow rules for agents +- **Never modify the odek project from a bodek session.** bodek is a pure + front-end; if a fix requires changes in odek (protocol, `odek serve`, + engine behaviour), do not edit that repo — instead summarize the required + change and suggest it to the user as a task for an odek session. - Don't run `git commit`/`git push` unless the user explicitly asks. - Don't add dependencies without checking `go.mod` first and flagging it to the user. diff --git a/internal/tui/events.go b/internal/tui/events.go index b8f0211..0e50b4e 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -131,6 +131,16 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.winCtxTok = ev.ContextTokens m.lastLatency = ev.Latency + case "usage": + // Per-iteration window fill from odek serve: keeps the header gauge + // live during multi-turn runs instead of waiting for "done". A zero + // value means the provider reported no usage — keep the last known + // fill rather than zeroing the gauge. + if ev.ContextTokens > 0 { + m.winCtxTok = ev.ContextTokens + } + stream = true + case "error": if i := m.cur(); i >= 0 && m.msgs[i].content == "" { m.msgs[i].content = "**Error:** " + ev.Message diff --git a/internal/tui/stats_test.go b/internal/tui/stats_test.go index fe2a696..8f72cbb 100644 --- a/internal/tui/stats_test.go +++ b/internal/tui/stats_test.go @@ -112,6 +112,41 @@ func TestGaugeFollowsTurnFill(t *testing.T) { } } +// TestUsageEventRefreshesGaugeMidTurn is a regression test: odek serve emits +// a per-iteration "usage" event during a run; the header gauge must refresh +// on it instead of staying stale until "done" arrives at the end of the +// whole agent loop. +func TestUsageEventRefreshesGaugeMidTurn(t *testing.T) { + m := newTestModel() + m.model = "big" + m.models = []client.ModelInfo{{ID: "big", MaxContext: 1000}} + m.resolveMaxContext() + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = len(m.msgs) - 1 + m.busy = true + m.status = "responding" + + m.handleEvent(client.Event{Type: "usage", ContextTokens: 400, OutputTokens: 50}) + if m.winCtxTok != 400 { + t.Fatalf("winCtxTok = %d, want 400", m.winCtxTok) + } + if out := plain(m.header()); !strings.Contains(out, "40%") { + t.Errorf("gauge should refresh mid-run (40%%), got:\n%s", out) + } + + // A zero-usage event (provider without usage reporting) must not zero a + // previously known fill. + m.handleEvent(client.Event{Type: "usage"}) + if m.winCtxTok != 400 { + t.Fatalf("winCtxTok = %d, want 400 (zero usage ignored)", m.winCtxTok) + } + + // The mid-run state must not be disturbed: still busy, still responding. + if !m.busy || m.status != "responding" { + t.Errorf("usage event must not end the turn: busy=%v status=%q", m.busy, m.status) + } +} + func TestContextGauge(t *testing.T) { m := newTestModel() m.model = "big"