diff --git a/internal/tui/cockpit.go b/internal/tui/cockpit.go index 3909ce8..dbf26ad 100644 --- a/internal/tui/cockpit.go +++ b/internal/tui/cockpit.go @@ -145,7 +145,7 @@ func (m *Model) cockpitBudgetSection() string { rows = append(rows, [2]string{"tool calls", fmt.Sprintf("%d", l.MaxToolCalls)}) } if l.MaxCostUSD > 0 { - spend := formatUSD(costUSD(m.sessCtxTok, m.sessOutTok, inPrice, outPrice)) + spend := formatUSD(costUSD(m.sessCtxTok, m.sessOutTok, inPrice, outPrice) + m.subCostTotal()) rows = append(rows, [2]string{"cost cap", fmt.Sprintf("%s of %s", spend, formatUSD(l.MaxCostUSD))}) } if inPrice > 0 && outPrice > 0 { diff --git a/internal/tui/commands.go b/internal/tui/commands.go index ffb7193..8907362 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -391,7 +391,7 @@ func (m *Model) statsBody() string { // otherwise the client-side twin resolves it. Hidden unless odek has // both token prices configured. if inPrice, outPrice := m.prices(); inPrice > 0 && outPrice > 0 { - costVal := th.statsValue.Render(formatUSD(costUSD(m.sessCtxTok, m.sessOutTok, inPrice, outPrice))) + costVal := th.statsValue.Render(formatUSD(costUSD(m.sessCtxTok, m.sessOutTok, inPrice, outPrice) + m.subCostTotal())) if m.limits.MaxCostUSD > 0 { costVal += th.statsDim.Render(" · cap " + formatUSD(m.limits.MaxCostUSD)) } diff --git a/internal/tui/events.go b/internal/tui/events.go index 260e1ca..df97b08 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -324,6 +324,9 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { // Per-task lifecycle telemetry (odek v1.30+): attach to the // in-flight sub-agent step; strays (resumed turn, idle, late // frames) fall back to a notice so nothing vanishes silently. + // The finished frame's final cost banks first — spent is spent + // even when the frame has no step left to attach to. + m.recordSubCost(ev) if i := m.cur(); i >= 0 && m.attachSubState(i, ev) { stream = true // coalesce redraws — state frames arrive in bursts m.subagentTerminalNote(ev) diff --git a/internal/tui/model.go b/internal/tui/model.go index 285c5da..3b2ce7c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -238,6 +238,7 @@ type Model struct { usageSnap *client.Usage sessCtxTok int + subCosts map[string]float64 // finished sub-agent final costs by task id (wire v2 P6) sessOutTok int winCtxTok int // live context-window fill: last request's prompt size runCtxCum int // last cumulative run contextTokens seen (odek reports per-run @@ -900,6 +901,7 @@ func (m *Model) clearConversation() { m.sessionStart = time.Time{} m.sessCtxTok = 0 m.sessOutTok = 0 + m.subCosts = nil m.winCtxTok = 0 m.runCtxCum = 0 m.lastLatency = 0 diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 56cd28c..5d3d46f 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -909,6 +909,7 @@ func (m *Model) handleSessionDetail(msg sessionDetailMsg) tea.Cmd { m.sessionStart = time.Time{} m.sessCtxTok = 0 m.sessOutTok = 0 + m.subCosts = nil m.winCtxTok = 0 m.runCtxCum = 0 m.lastLatency = 0 diff --git a/internal/tui/subagent_cost_test.go b/internal/tui/subagent_cost_test.go new file mode 100644 index 0000000..312922c --- /dev/null +++ b/internal/tui/subagent_cost_test.go @@ -0,0 +1,124 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// Sub-agent LLM calls cost real money, and odek reports each finished +// task's final spend (cost_usd on the finished subagent_state frame — +// wire v2 P6). The session-cost surfaces — header, /stats, and the +// cockpit cap row — must add that spend on top of the main-loop token +// estimate, summed once per task id so replayed frames never double-count. + +// subCostFixture: prices configured; two sub-agent tasks finish with +// engine-reported costs; the main loop burns $0.016 of tokens +// (10k in @ $1/M + 2k out @ $3/M). +func subCostFixture(t *testing.T) *Model { + t.Helper() + m := newTestModel() + m.limits = client.Limits{InputCostPerMillionUSD: 1.0, OutputCostPerMillionUSD: 3.0} + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "started", Status: "running"}) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t2", TaskIdx: 1, Phase: "started", Status: "running"}) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "success", CostUSD: 0.0125}) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t2", TaskIdx: 1, Phase: "finished", Status: "success", CostUSD: 0.005}) + return driveTurnWith(t, m, client.Event{ + Type: "done", Latency: 1, + ContextTokens: 10_000, OutputTokens: 2_000, + SessionContextTokens: 10_000, SessionOutputTokens: 2_000, + }) +} + +// TestSubagentCostAddsToHeader: header session spend = main loop + the +// finished tasks' engine-reported costs ($0.016 + $0.0175 = $0.0335). +func TestSubagentCostAddsToHeader(t *testing.T) { + m := subCostFixture(t) + if out := plain(m.header()); !strings.Contains(out, "$0.0335") { + t.Errorf("header missing sub-agent-inclusive session cost:\n%s", out) + } +} + +// TestSubagentCostReplayIsIdempotent: duplicated/late finished frames +// upsert per task id — the total stays $0.0335, never doubles. +func TestSubagentCostReplayIsIdempotent(t *testing.T) { + m := subCostFixture(t) + for _, ev := range []client.Event{ + {Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "success", CostUSD: 0.0125}, + {Type: "subagent_state", TaskID: "t2", TaskIdx: 1, Phase: "finished", Status: "success", CostUSD: 0.005}, + } { + m.handleEvent(ev) + } + if out := plain(m.header()); !strings.Contains(out, "$0.0335") { + t.Errorf("replayed finish frames changed the session cost:\n%s", out) + } +} + +// TestStatsCardIncludesSubagentCost: the /stats cost row carries the +// finished task's spend too ($0.016 + $0.0125 = $0.0285). +func TestStatsCardIncludesSubagentCost(t *testing.T) { + m := newTestModel() + m.limits = client.Limits{ + InputCostPerMillionUSD: 1.0, + OutputCostPerMillionUSD: 3.0, + MaxCostUSD: 5, + } + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "success", CostUSD: 0.0125}) + m = driveTurnWith(t, m, client.Event{ + Type: "done", Latency: 1, + ContextTokens: 10_000, OutputTokens: 2_000, + SessionContextTokens: 10_000, SessionOutputTokens: 2_000, + }) + m.showStats() + if out := plain(m.View()); !strings.Contains(out, "$0.0285") { + t.Errorf("stats card missing sub-agent-inclusive cost row:\n%s", out) + } +} + +// TestCockpitCapRowIncludesSubagentCost: the cap row compares total spend +// (main + sub-agents) against the configured cost cap. +func TestCockpitCapRowIncludesSubagentCost(t *testing.T) { + m := subCostFixture(t) + m.limits.MaxCostUSD = 0.5 + out := plain(m.cockpitBudgetSection()) + if !strings.Contains(out, "$0.0335") || !strings.Contains(out, "$0.50") { + t.Errorf("cap row missing sub-agent-inclusive spend:\n%s", out) + } +} + +// TestSubagentNoCostReportsNothing: a task that finishes without a +// reported cost adds nothing — absent cost is unavailable, never $0 — +// so the header keeps showing the plain main-loop estimate. +func TestSubagentNoCostReportsNothing(t *testing.T) { + m := newTestModel() + m.limits = client.Limits{InputCostPerMillionUSD: 1.0, OutputCostPerMillionUSD: 3.0} + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "success"}) + m = driveTurnWith(t, m, client.Event{ + Type: "done", Latency: 1, + ContextTokens: 10_000, OutputTokens: 2_000, + SessionContextTokens: 10_000, SessionOutputTokens: 2_000, + }) + if out := plain(m.header()); !strings.Contains(out, "$0.016") { + t.Errorf("absent sub-agent cost must not distort session cost:\n%s", out) + } +} + +// TestClearResetsSubagentCost: /clear wipes every session counter — the +// recorded sub-agent costs included — so a post-clear turn shows only the +// new session's spend. +func TestClearResetsSubagentCost(t *testing.T) { + m := newTestModel() + m.limits = client.Limits{InputCostPerMillionUSD: 1.0, OutputCostPerMillionUSD: 3.0} + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "success", CostUSD: 0.0125}) + m.Update(key("ctrl+l")) + m.Update(key("y")) + m = driveTurnWith(t, m, client.Event{ + Type: "done", Latency: 1, + ContextTokens: 10_000, OutputTokens: 2_000, + SessionContextTokens: 10_000, SessionOutputTokens: 2_000, + }) + if out := plain(m.header()); !strings.Contains(out, "$0.016") { + t.Errorf("clear did not reset sub-agent cost — header shows:\n%s", out) + } +} diff --git a/internal/tui/subagents.go b/internal/tui/subagents.go index f1a0a39..83fd519 100644 --- a/internal/tui/subagents.go +++ b/internal/tui/subagents.go @@ -490,6 +490,29 @@ func (m *Model) untrackLive(taskID string) { } } +// recordSubCost banks a finished task's engine-reported final cost (wire +// v2 P6): upserted per task id, so replayed frames overwrite instead of +// doubling. Stray frames still count — cost spent is cost spent. A zero +// or absent cost records nothing: unavailable is never $0. +func (m *Model) recordSubCost(ev client.Event) { + if ev.Phase != "finished" || ev.TaskID == "" || ev.CostUSD <= 0 { + return + } + if m.subCosts == nil { + m.subCosts = make(map[string]float64) + } + m.subCosts[ev.TaskID] = ev.CostUSD +} + +// subCostTotal sums the banked sub-agent costs; 0 when nothing reported. +func (m *Model) subCostTotal() float64 { + var total float64 + for _, c := range m.subCosts { + total += c + } + return total +} + // liveCard finds an unfinished card by task id: the cross-turn live registry // first, then the current-turn scan as a fallback. func (m *Model) liveCard(taskID string) *agentCard { diff --git a/internal/tui/view.go b/internal/tui/view.go index 4712cd3..f79c7f7 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -99,10 +99,11 @@ func (m *Model) header() string { // Sandbox status, prominently colored: green ● when isolated, amber ▲ // when the agent has host access. tail += th.headerMeta.Render(" · ") + m.sandboxBadge() - // Session spend rides the left cluster; hidden until odek reports both + // Session spend rides the left cluster: main-loop tokens plus every + // finished sub-agent's reported cost; hidden until odek reports both // token prices (never show a guessed $0). if inPrice, outPrice := m.prices(); inPrice > 0 && outPrice > 0 { - tail += th.headerMeta.Render(" · ") + th.headerKey.Render(formatUSD(costUSD(m.sessCtxTok, m.sessOutTok, inPrice, outPrice))) + tail += th.headerMeta.Render(" · ") + th.headerKey.Render(formatUSD(costUSD(m.sessCtxTok, m.sessOutTok, inPrice, outPrice)+m.subCostTotal())) } status := m.statusBadge()