Skip to content

fix(mobile): one canonical session cost across list and detail - #4777

Merged
iscekic merged 7 commits into
mainfrom
fix/mobile-cost-and-exit
Jul 27, 2026
Merged

fix(mobile): one canonical session cost across list and detail#4777
iscekic merged 7 commits into
mainfrom
fix/mobile-cost-and-exit

Conversation

@iscekic

@iscekic iscekic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Problem

The cost shown for a session in the Agents list differed from the cost on the session
detail screen. Example (measured against API + DB and confirmed on device):

Session (DB µ$) List rendered Detail rendered Spoken
13113 ($0.013113) $0.01 $0.0131 "cost 1 cent"
3081 ($0.003081) <$0.01 $0.0031 (nothing)

The numbers were identical to the microdollar — the defect was display/derivation
divergence, not bad data. One quantity had two independent derivations (persisted DB
column vs live client sum over loaded messages) and four independent renderings (list
visible, list spoken, detail header, context sheet):

  • List read cli_sessions_v2.total_cost_microdollars and formatted at 2 decimals
    with a <$0.01 floor that hid real sub-cent values.
  • Detail summed loaded messages client-side and formatted at 4 decimals, with a
    second hand-rolled toFixed(4) copy in the header fallback. It under-reported on
    paginated sessions and showed no cost at all when the DO snapshot came back empty
    (list $0.07, detail blank), or a false $0.0000 in the context sheet when loaded
    assistant cost was 0.
  • Spoken labels rounded to whole cents and said nothing for sub-cent costs that the
    screen displayed.

Fix

One canonical value in integer microdollars, one visible formatter, one spoken
humanizer, one selector:

  • getWithRuntimeState exposes total_cost_microdollars (output schema and
    return projection), threaded through FetchedSessionData (optional, web unchanged) to
    mobile fetchSession. No new query — the detail screen already fetches this object.
  • formatSessionTotalCost(µ$) is the only session-total formatter: >= $0.005
    renders 2 decimals; below renders the real 4-decimal figure instead of <$0.01;
    values that would render a false $0.0000 (1–49 µ$) are omitted. Used by the list
    row, detail header pill, header fallback (both inline toFixed(4) copies deleted),
    and the context-sheet total.
  • formatSpokenCost(µ$) is the single humanizer for list and detail a11y, branching
    on the same 5000 µ$ threshold: whole-cent phrasing above ("1 cent"), fractional cents
    below ("0.31 cents"), and null exactly when the visible value is null — never "no
    cost" for a session that has one.
  • selectSessionCostInputs(persisted, liveUsd) derives the detail total as
    max(persisted µ$, live µ$). Session cost is monotonically non-decreasing, so both
    are lower bounds and the max is strictly closer to truth. This kills all three detail
    failure modes: empty snapshot (persisted wins), pagination (persisted wins), and new
    turns while on screen (live wins). The context sheet receives the combined total for
    its "Total cost" row but keeps the live USD sum for its per-model breakdown, so no
    phantom "Subagents" residual appears on paginated sessions. When no cost exists, the
    sheet omits the "Total cost" row entirely rather than showing $0.0000.
  • SessionIngestDO persists the cost column before the O11Y metrics RPC. The
    best-effort Postgres write previously sat after the unguarded analytics call, so an
    O11Y failure skipped it and the column stayed NULL (demonstrated locally with
    cloudflare-o11y down: Worker "o11y" not found, no persisted cost). The O11Y call
    stays unguarded and the metricsEmitted dedup is unchanged.

formatCost (4-decimal USD) remains only for attribution detail: per-model rows, the
subagent-residual row, and per-message rows.

Test plan

  • Unit: full formatter boundary vectors (null/0/negative/non-finite, 1–49 µ$ omit band,
    50/3081/4999/5000/9999/10000/13113/1234567 µ$), spoken vectors, the selector matrix
    including the composition gate ((500_000, 0.001) → total 500_000, breakdown
    0.001), and rewritten header/sheet/a11y expectations (microdollar inputs, humanized
    spoken fragments, cost: null instead of '$0.0000').
  • pnpm format && pnpm typecheck && pnpm lint && pnpm check:unused && pnpm test clean in
    apps/mobile (1876 tests); apps/web typecheck + router/SDK jest suites (237 tests) +
    lint; services/session-ingest typecheck + tests (595) + lint.
  • On-device E2E against the local stack: fresh CLI sessions in both cost classes
    (≥ 1¢ and sub-cent), persisted cost proven non-null in Postgres first, list refetched,
    then list row / detail header / context-sheet total compared for the identical string,
    and spoken labels read from the accessibility tree.

Out of scope / follow-ups

  • Dropped item: a report that /exit appears to change the session owner. Not
    reproducible across a 4-path on-device matrix (CLI 7.4.13 and 7.4.16 × typed /exit
    and mobile-initiated exit); kilo_user_id/organization_id unchanged in every path,
    typed /exit issues no HTTP request, no writer of kilo_user_id exists in the repo,
    and the composite PK (session_id, kilo_user_id) makes owner-overwrite-by-upsert
    structurally impossible. Nothing in this PR addresses it. Two real follow-ups found
    along the way, reported here but deliberately not fixed in this PR:
    1. After the CLI process dies, the list can accumulate stray untitled UNKNOWN rows
      (separate empty sessions, not a re-owned session) — plausibly what the reporter
      saw. Deserves its own investigation.
    2. Authorization gap: session-ingest writes cli_sessions_v2.organization_id
      verbatim from the client-supplied kilo_meta.orgId with no membership check
      (services/session-ingest/src/ingest/metadata.ts), while the tRPC path checks via
      ensureOrganizationAccess. Can re-tenant a session away from its owner. Needs its
      own change and security review.
  • The web app has the identical list-vs-detail divergence (CloudChatPage hand-rolls
    toFixed(4), the sidebar reads the persisted column). Shared-SDK changes here are
    additive; aligning the web UI is a separate change.
  • While a turn is in flight the detail (live sum) and list (last persisted value)
    legitimately differ for a few seconds; they converge after the turn closes. If the
    user stays on the detail screen across new turns with older pages unloaded, both
    selector inputs can be low at once — still strictly better than today, which showed
    the partial live sum alone.

iscekic added 3 commits July 26, 2026 07:25
…meState

Add the persisted per-session cost column to the getWithRuntimeState
output schema and return projection, thread it through FetchedSessionData
as an optional field, and populate it in the mobile fetchSession mapping.
The mobile session detail screen will use it to render the same canonical
cost the list reads from the Postgres column. Web fetchSession behavior
is unchanged (the new field is optional).
The list rendered the persisted column at 2 decimals with a </bin/zsh.01 floor
while the detail rendered a client-side message sum at 4 decimals, plus a
hand-rolled toFixed(4) duplicate in the header fallback, and spoken labels
that dropped sub-cent costs entirely. The same session read as up to four
different costs, and an empty-snapshot session showed its cost in the list
but nothing on the detail screen.

Derive every surface from one canonical microdollar total: the detail
combines the persisted column and the live message sum via
selectSessionCostInputs (max of two lower bounds, cost being monotonic),
all surfaces render through formatSessionTotalCost, and all spoken labels
through formatSpokenCost on the same threshold. The context sheet splits
its total (combined microdollars) from its breakdown input (live USD sum)
so no phantom subagent residual appears, and omits the Total cost row
instead of rendering $0.0000 when no cost has been reported.
The best-effort total_cost_microdollars write sat after the unguarded
O11Y ingestSessionMetrics call, so an analytics binding failure skipped
the cost persist entirely and the column stayed NULL. Reorder so the cost
write runs first; the O11Y call stays unguarded and the metricsEmitted
dedup is unchanged, preserving the existing alarm retry semantics.
@iscekic iscekic self-assigned this Jul 26, 2026
@iscekic

iscekic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

(bot) Standin code review of head d0b1f7fca, posted by the mobile workflow orchestrator. The automated Kilo Code Review check completes green but posts no findings for this PR, and the review bot account replies that it cannot trigger the automated review summary — so this review was produced by a dedicated standin reviewer agent (fresh session, complete diff, read-only). It is a standin review, not a Kilobot review.

Scope reviewed: complete git diff origin/main...HEAD (13 files, 4 commits; head commit verified empty retrigger) — mobile formatters/selector/components, web router + SDK type, SessionIngestDO. AGENTS.md invariants checked for root, apps/mobile/, apps/web/, services/, packages/db/.

Checks run by the reviewer (all clean): mobile vitest 226 files / 1876 tests pass; mobile typecheck, lint, check:unused, format:check; web typecheck; web cli-sessions-v2-router.test.ts 68/68 pass; session-ingest typecheck + 595 tests pass.

Verified correct by inspection: omit band (1–49 µ$ → null, confirmed by the passing boundary vectors), the shared >= 5000 µ$ visible/spoken threshold, selectSessionCostInputs max() semantics and the composition gate (breakdown never receives the combined total), µ$↔USD conversion consistency across DO persist / live atom / formatters, the cross-repo contract (bigint mode:'number' nullable ↔ z.number().nullable() ↔ optional SDK field ↔ mobile mapping), the stale-session guard on fetchedData.kiloSessionId, a11y wiring (spoken humanizer everywhere the visible value appears; no double-reading), graceful degradation when the fetch fails, DO persist idempotent under alarm retry, metricsEmitted dedup and unguarded O11Y semantics unchanged, no secrets logged, no stray toFixed(4) copies remain.

Finding 1 — minor — missing regression coverage for the DO reorder guarantee

  • Location: services/session-ingest/src/dos/SessionIngestDO.ts:707-738 (changed block); the gap is in services/session-ingest/src/dos/SessionIngestDO.test.ts.
  • What: commit 202a27e3 exists solely to guarantee "the Postgres cost persist runs even when O11Y.ingestSessionMetrics throws", yet no test exercises emitSessionMetrics at all — the O11Y binding is never mocked in the service's tests, so the ordering guarantee is protected only by code review. A future refactor that moves the O11Y call back above the persist block would silently re-introduce the NULL-cost bug this PR fixes, with every suite still green.
  • Evidence: rg "ingestSessionMetrics" services/session-ingest/src matches only o11y-binding.d.ts:11 and the implementation at SessionIngestDO.ts:732 — no test file.

Verdict: 1 actionable finding (minor). Triage: valid — a regression test pinning the persist-before-O11Y ordering will be added; the result will be recorded in a follow-up comment here.

Regression coverage for the guarantee that the Postgres
total_cost_microdollars persist completes before the unguarded
O11Y.ingestSessionMetrics RPC: drives alarm() with O11Y rejecting and
asserts the update chain (connection, value, session/user filter) was
awaited first. Marker records at await time via a thenable, so a
build-now-await-later refactor also fails.
@iscekic

iscekic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

(bot) Triage outcome for the standin review above. Finding 1 (minor, missing regression coverage for the DO reorder guarantee) was valid and is now repaired on head 9e3fcba07:

  • services/session-ingest/src/dos/SessionIngestDO.test.ts (+247): new test persists total_cost_microdollars before O11Y when O11Y rejects drives alarm()emitSessionMetrics with O11Y.ingestSessionMetrics rejecting, and asserts the mocked getWorkerDb(...).update().set({ total_cost_microdollars: 150_000 }).where(...) chain completed before the O11Y call — with the persist marker recorded at await time (custom thenable) and the update filter pinned to the session and user ids.
  • Sensitivity proven by the implementer and re-verified by a fresh reviewer: the test fails if the O11Y call is moved above the persist block, fails for a build-now-await-later shape, and fails on a wrong session id in the filter (each demonstrated, then reverted; production code byte-identical).
  • Checks: services/session-ingest 596 passed / 3 skipped (+1), typecheck and lint clean. A fresh reviewer over the final repair reports no actionable findings. Local E2E intentionally not rerun: test-only change, no behavior/build/runtime-config delta.

@iscekic

iscekic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

(bot) Standin review round 2 on the final head 9e3fcba07 (same arrangement as the review above: a dedicated read-only reviewer agent over the complete git diff origin/main...HEAD, posted because the automated reviewer posts nothing).

Scope: full diff, 14 files / 5 commits — mobile components/helpers/tests, web router + SDK, session-ingest DO + the new ordering test. Surrounding code verified: DB column (packages/db/src/schema.ts:4903, nullable bigint number ↔ z.number().nullable()), router query/projection (cli-sessions-v2-router.ts:847–948), SDK atom semantics (totalCostAtom live sum; fetchedSessionDataAtom cleared on switch), mobile's cloud-agent-sdk aliasing to the edited web sources, all consumers of renamed/changed props, omit-band equivalence between formatSessionTotalCost and formatSpokenCost.

Checks run by the reviewer (all clean): mobile typecheck, lint, check:unused, format:check, full vitest (226 files / 1876 tests); session-ingest typecheck, lint, vitest (24 files / 596 tests including the new O11Y-ordering pin); web typecheck (with @kilocode/trpc build), lint, cli-sessions-v2 router jest suite (68 tests).

Result: no findings. Verdict: no actionable findings on the latest head.

Residual testing risks noted by the reviewer (informational, not findings): (1) list-vs-detail convergence with a non-zero persisted cost is covered at unit level and by the on-device E2E run reported in the PR description, not by Maestro automation; (2) the new ordering test relies on a hand-rolled drizzle mock — it pins ordering and where-binding but would not catch a real drizzle SQL semantics change; (3) sub-half-cent visible/spoken rounding can diverge by ≤0.01 cent at exact decimal boundaries — deterministic, cosmetic, and consistent with the documented rounding rule.

This completes the review loop: a fresh standin reviewer reports no valid actionable findings on the final head. The PR is ready for human review.

@iscekic

iscekic commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

(bot) Final state on head 82364623:

  • The format-check CI job failed on the previous head (oxfmt diff in the new test file — an oversight in my repair brief, which required test/typecheck/lint but not format). Fixed by a mechanical oxfmt run in 82364623 (single-signature line join, zero behavioral delta); pnpm test (596 passed), typecheck, and lint re-verified locally before push.
  • Standin review round 3 on the exact final head 82364623280369d985c760f2e912f52daa8d01af (fresh read-only reviewer, complete git diff origin/main...HEAD, 14 files, 6 commits, cross-repo contract chain re-verified): No findings. Verdict: no actionable findings.
  • All CI checks on the final head are in a successful terminal state, including test (cloudflare-session-ingest), test (kilo-app), build, format-check, lint, typecheck, check-unused, drizzle-check, CodeQL, and trufflehog. GitHub reports the PR MERGEABLE.

Review loop is closed: two standin reviews found and fixed one valid minor finding (DO persist-before-O11Y regression coverage, hardened through two nit rounds), and the final fresh standin review is clean. Ready for human review — per workflow this PR is never self-approved or merged by the bot.

@iscekic
iscekic enabled auto-merge (squash) July 26, 2026 15:54
@iscekic
iscekic disabled auto-merge July 26, 2026 15:58
@iscekic
iscekic enabled auto-merge (squash) July 27, 2026 09:07
@iscekic
iscekic merged commit b610ec1 into main Jul 27, 2026
16 of 18 checks passed
@iscekic
iscekic deleted the fix/mobile-cost-and-exit branch July 27, 2026 09:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants