Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/tui/cockpit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
3 changes: 3 additions & 0 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/tui/panels.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions internal/tui/subagent_cost_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
23 changes: 23 additions & 0 deletions internal/tui/subagents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down