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
40 changes: 40 additions & 0 deletions src/__tests__/harnessClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,46 @@ describe('HarnessGatewayClient', () => {
})
})

it('bills the child\'s tokens to the session odometer, not just its own row', async () => {
const w = await delegating()

// The parent's own step…
w.fire('assistant/message', {
message: { content: [{ text: 'delegating', type: 'text' }], id: 'p1', role: 'assistant', source: { kind: 'model' } },
step: 1,
turn: 1,
usage: { inputTokens: 100, outputTokens: 10 }
})
// …and the child's, which used far more than the parent did. Both are
// the same bill; only the child's row used to hear about the second.
w.fireFrom(CHILD, 'assistant/message', {
message: { content: [{ text: 'done', type: 'text' }], id: 'm1', role: 'assistant', source: { kind: 'model' } },
step: 1,
turn: 1,
usage: { cacheReadTokens: 400, inputTokens: 500, outputTokens: 60 }
})

await expect(w.client.request('session.usage', {})).resolves.toMatchObject({
calls: 2,
input: 1000,
output: 70,
total: 1070
})
})

it('ignores a sibling session that was never announced as a child', async () => {
const w = await delegating()

w.fireFrom('some-other-session', 'assistant/message', {
message: { content: [{ text: 'not ours', type: 'text' }], id: 'x1', role: 'assistant', source: { kind: 'model' } },
step: 1,
turn: 1,
usage: { inputTokens: 999, outputTokens: 999 }
})

await expect(w.client.request('session.usage', {})).resolves.toMatchObject({ calls: 0, input: 0, output: 0 })
})

const delegate = async (
w: ReturnType<typeof makeWorld>,
{ callId = 'd1', resultText = 'the child answer' }: { callId?: string; resultText?: string } = {}
Expand Down
62 changes: 59 additions & 3 deletions src/__tests__/sessionStats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
* Session-stats line under the composer — the deleted REPL's bottom toolbar:
* `provider · model · cwd · turns: N · tokens: X in / Y out · cost $C`.
*
* Covers the CostSnapshot fold, the width-aware line builder, the
* message.complete → ui.sessionStats patch, and the component render gate.
* Covers both folds — the priced CostSnapshot and the price-free `usage`
* odometer a backend like the dsh harness sends instead — plus their
* precedence, the width-aware line builder, the message.complete →
* ui.sessionStats patch, and the component render gate.
*/
import { PassThrough } from 'node:stream'

Expand All @@ -18,7 +20,7 @@ import { resetTurnState } from '../app/turnStore.js'
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
import { SessionStatsLine } from '../components/sessionStatsLine.js'
import type { CostSnapshot } from '../gatewayTypes.js'
import { buildSessionStatsLine, statsFromCostSnapshot, ZERO_SESSION_STATS } from '../lib/sessionStats.js'
import { buildSessionStatsLine, statsFromCostSnapshot, statsFromUsage, ZERO_SESSION_STATS } from '../lib/sessionStats.js'
import { stripAnsi } from '../lib/text.js'
import type { Msg } from '../types.js'

Expand Down Expand Up @@ -51,6 +53,23 @@ describe('statsFromCostSnapshot', () => {
})
})

describe('statsFromUsage', () => {
it('reads the cumulative odometer a price-free backend sends instead', () => {
// The harness folds cache reads/writes into `input` before it ships them,
// which is already what inputTokens means.
expect(statsFromUsage({ calls: 4, input: 33189, output: 622, total: 33811 }, 3)).toEqual({
costUsd: 0,
inputTokens: 33189,
outputTokens: 622,
turns: 3
})
})

it('reports spend only when the backend priced it', () => {
expect(statsFromUsage({ calls: 1, cost_usd: 0.0048, input: 10, output: 2, total: 12 }, 1).costUsd).toBe(0.0048)
})
})

describe('buildSessionStatsLine', () => {
const HOME = process.env.HOME

Expand Down Expand Up @@ -176,6 +195,43 @@ describe('message.complete → ui.sessionStats', () => {
})
})

it('folds a price-free backend\'s usage odometer when there is no snapshot', () => {
const onEvent = createGatewayEventHandler(buildCtx())

// The dsh harness has no price table, so it publishes token totals here
// and never a CostSnapshot. This read `tokens: 0 in / 0 out` all session.
onEvent({
payload: { session_turns: 1, text: 'done', usage: { calls: 2, input: 33189, output: 622, total: 33811 } },
type: 'message.complete'
} as any)

expect(getUiState().sessionStats).toEqual({ costUsd: 0, inputTokens: 33189, outputTokens: 622, turns: 1 })
})

it('prefers the snapshot over usage when a backend sends both', () => {
const onEvent = createGatewayEventHandler(buildCtx())

// On a snapshot-carrying backend `usage` describes the last call, not the
// session — the subagent-inclusive snapshot is the one to believe.
onEvent({
payload: { cost: SNAPSHOT, session_turns: 1, usage: { calls: 1, input: 12, output: 3, total: 15 } },
type: 'message.complete'
} as any)

expect(getUiState().sessionStats).toMatchObject({ inputTokens: 33189, outputTokens: 622 })
})

it('folds the usage rider a resume replays out of the session log', () => {
const onEvent = createGatewayEventHandler(buildCtx())

onEvent({
payload: { session_turns: 1, usage: { calls: 1, input: 1000, output: 120, total: 1120 } },
type: 'session.stats'
} as any)

expect(getUiState().sessionStats).toEqual({ costUsd: 0, inputTokens: 1000, outputTokens: 120, turns: 1 })
})

it('leaves sessionStats untouched when the payload carries neither field', () => {
const onEvent = createGatewayEventHandler(buildCtx())
const before = getUiState().sessionStats
Expand Down
12 changes: 12 additions & 0 deletions src/harness/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,10 @@ export class HarnessGatewayClient extends GatewayClient {
* `assistant/message` carries the `usage` the adapter reported — so a
* resumed session has no reason to restart its counters at zero while
* `turns:` keeps counting; that mismatch was visible on the stats line.
*
* Only this session's own steps: a subagent's live in the child's log, and
* this session never held those events. Live delegations still fold in
* through `onChildSessionEvent`.
*/
private replayUsage(events: readonly SessionEvent[]): Usage {
const totals: Usage = { calls: 0, input: 0, output: 0, total: 0 }
Expand Down Expand Up @@ -753,6 +757,14 @@ export class HarnessGatewayClient extends GatewayClient {
if (usage) {
child.inputTokens += usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
child.outputTokens += usage.outputTokens
// …and onto the session odometer, because a delegated step is spend
// on this session's behalf: it is the same bill, and the stats line
// and /status have always claimed to count subagents. Only the
// child's own row counted it before, so a run that fanned out
// reported a fraction of what it actually used. The two sides of
// the bus are disjoint (bindAgent routes by session identity), so
// nothing is counted twice.
foldUsage(this.usageTotals, usage)
}

// One event per step rather than one per delta: a child's reasoning
Expand Down
Loading