2.6.C — the reseat no longer blanks the screen, and /cost shows where the money went#75
Conversation
PR #74 is merged, so close out the phase's canonical docs: - ADR-0069 → Accepted. The maintainer merging PR #74 (which carries the string-width dependency) is the CLAUDE.md rule-2 approval this ADR gated; flip Status Proposed→Accepted and rewrite the pending callout to record it. - roadmap/current.md: 2.6.F is "merged to main (PR #74)", not "complete on development"; ADR-0069 no longer Proposed; drop the ADR-0069 open obligation; add the copy-on-select confirmation toast to the shipped list; bump the date. - accessibility.md + chat-session.md: document the new "✓ Copied" toast, scoped honestly — it fires when Relavium EMITS OSC 52, which (OSC 52 having no acknowledgement) is not the same as the terminal accepting the copy. Docs-only; no behaviour change. Refs: ADR-0068, ADR-0069 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The canonical status home (current.md) already records 2.6.F as merged, but the phase plan still read "Planned — next up" with an unmarked 2.6.F section. Align it to the house style used across phase-2.5: - Top status → "In progress", noting 2.6.F is Done (merged, PR #74). - The 2.6.F header gets the "— ✅ Done (PR #74, 2026-07-11)" suffix and a Status blockquote naming ADR-0067/0068/0069 (all Accepted). Per the one-canonical-home rule, the shipped inventory and open obligations are not restated — the blockquote points to current.md. Docs-only. Refs: ADR-0068, ADR-0069 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Investigated five hands-on findings against the current code (traced + verified), and placed each in the roadmap. Also lands the models.dev enrichment design analysis the catalog findings rest on (+ its analysis-index entry). Findings → placement: - F1 reseat drops the on-screen transcript (confirmed bug, high; the reseat store is seeded header-only, exposed by 2.6.F's scrollback-less alt buffer — DB and model context are intact) → 2.6.C, near-hotfix. No new ADR (under ADR-0059). - F2 /models floods with every provider's models; no curation → a curated `[preferences].models_in_use` set in 2.6.L (/settings) + the new-provider default policy in 2.6.I. Empty set shows all (back-compatible). - F3 opaque `error: validation` on effort + F4 unclamped max_tokens are ONE capability-matrix gap (per-model accepted-tier set / output ceiling, clamp before the wire) → new workstream 2.6.Q; F4's authoring-pre-flight half → 2.6.A. - F5 unpriced models run with the cost cap silently off (budget governor catches UnknownModelError and allows) — a safety hole → 2.6.Q pricing tier + a data-source-independent governor hardening shipped first. New workstream 2.6.Q "Dynamic model-catalog enrichment (models.dev)": capability matrix (F3+F4, near-zero risk, first) + pricing long-tail/cost-cap safety (F5, guardrailed) as two payloads of one host-side enrichment. Day-1 independent; hard-gates nothing. New ADR extending ADR-0064, mandatory security review co-gate. Ripple: workstream count 16→17; M2.6-5 + dependency matrix + parallelization tracks + mermaid + ADR table + risks updated. Open ADR-time decisions left explicit (default egress posture; clamp vs reject; new-provider default). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
commands.md delegates the in-chat behaviour of /models and /effort to chat-session.md "and its ADRs — this reference does not restate them", but chat-session.md had neither a /models row, an /effort row, nor a reseat section. The pointer dangled: the reseat had no canonical home to document, which is a rule-8 hole and the reason 2.6.C's fix had nowhere to land. - Add a "Model reseat (/models)" section: the host-side swap (a new AgentSession.resume bound to the chosen model, carrying cumulative cost and turn count), what it does NOT carry (a fresh fallback chain, not the old agent's; a text-only transcript, so no prior tool calls or file contents), and the per-message modelId attribution with its explicit `unknown` (pre-attribution) bucket. Behaviour only — the decision stays in ADR-0059. - Add the /models and /effort slash rows, sourced from the REPL registry's own descriptions, so commands.md's delegation now resolves for both. - Narrow the stale lifetime claim. "A session binds one agent AND ONE MODEL for its whole lifetime — no mid-session switching in Phase 1" has been false since 2.5.G shipped the reseat (PR #66). It now reads as ADR-0059 itself words it: the AGENT is bound for the lifetime; each AgentSession INSTANCE has exactly one model, so ADR-0024's rule is refined, not reversed. Docs-only; no behaviour change. Step 1 of 2.6.C — it gives the transcript-carry fix a home to update. Refs: ADR-0059, ADR-0024 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t that does not exist An aggressive review of 56215e7 confirmed 30 findings against source. The new canonical home — the page 2.6.C's code will be written against — got the reseat materially wrong. Folding every confirmed finding: Accuracy (the page stated behaviour the code does not have): - Distinguish the ENGINE transcript from the RENDERED one. The section never did, and that distinction IS 2.6.C: the engine side is reconstructed and replayed text-only; the view side is what the reseat currently drops. - The effort sub-step PRECEDES the reseat and rides into it (it is bound on the new instance, live from its FIRST turn) — the page said "followed by … applies from the next turn". Code written against that would have issued a second, wrong mutation. - The "does not carry" list omitted the two most conspicuous drops: the chat mode resets to fail-closed `ask`, and the ADR-0057 approval once/always cache is instance-scoped, so previously-approved tools RE-PROMPT. ADR-0059 records the re-prompt as a Negative consequence; the behaviour home did not. A live `/effort` override does not survive either. - Cost carries forward; the turn count is RE-DERIVED from the reconstruction, so a reseat after `/compact`/`/trim` (or an errored turn) resumes lower. - Attribution: only ASSISTANT rows carry a modelId, and the value is the `model_catalog` id, not the model string. - `/effort` is NOT capability-gated — it is accepted on any model and stored inert; the picker sub-step and the footer indicator are the gated surfaces. - `/models` is interactive-only; the bare-Home accept writes the GLOBAL `[preferences].default_model`, not `[chat].default_model`. Aspirational content (the class this review existed to catch — cf. the home-controller comment that claims a carried transcript the code never carries): - The `unknown` (pre-attribution) bucket was written in the present tense. No per-model reader exists — the same page says so eight lines down. It is now a labelled "not yet shipped (2.6.C)" marker. Rule 8: - Step 1 closed one dangling pointer and opened another: it aimed the bare-Home write-default at home.md, which forwards to commands.md, which forwards back here — a three-page citation cycle. Repointed at the config spec, the actual home. Dropped the ADR rationale clauses the reference had lifted. - Added the `/scrollback`, `/edit`, `/copy` rows — the same rule-8 hole Step 1 closed for `/models` and `/effort`. Residual staleness of the same class, swept: - agent-session-spec.md (the page chat-session.md cites as its authority), architecture/agent-sessions.md, and vscode/extension-api.md all still bound the `fallback_chain` for the whole conversation — falsified by the reseat, which rebuilds it. Narrowed all three: the AGENT is bound for the conversation; the MODEL and its chain belong to the instance. - home.md still advertised the `/models` picker as unbuilt; it landed in 2.5.G. - glossary.md gained the load-bearing `Reseat` term. - chat-session.md's own metadata never said it had become the reseat's home. Docs-only; no behaviour change. Refs: ADR-0059, ADR-0057, ADR-0063, ADR-0064, ADR-0066, ADR-0068 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…the screen goes blank A second review pass caught what the first fold claimed to fix but did not. BLOCKER. The reseat section opened "two different things are called the transcript, and a reseat treats them differently" — then described only the engine-side one. The second bullet was about cost and turn count, not a transcript at all. So the canonical home STILL never disclosed the behaviour 2.6.C exists to fix: the RENDERED transcript is not carried, and on the full-screen alt screen (the TTY default, with no native scrollback) the conversation disappears from the screen. The previous commit message asserted this was fixed. It was not. It is now — stated as current fact, with the 2.6.C fix marked as the tracked gap. The durable transcript and the model's context were never at risk; this is a view gap, and the page now says so. Accuracy: - The /models effort sub-step has TWO outcomes and only one reseats. Picking a DIFFERENT model bakes the tier into the new instance; re-picking the model you are already on is a live per-turn override with NO reseat (home-controller.ts:794 branches on the model id; the same-model branch calls applyEffortOnlyUpdate). The page implied every accepted pick reseats. config-spec.md and agent-yaml-spec.md already said otherwise. - Attribution resolves by model STRING, not by provider, so an id shared across two providers can mis-attribute — a shipped edge that lived only in ADR-0059. Rule 8: - The /scrollback,/edit,/copy rows I added in the last commit near-verbatim restated the hatches prose already on the same page. That is a duplication I introduced while closing a different rule-8 hole. Shrunk to name + effect + a link to the prose, matching how /clear and /compact already cross-link. - Added the missing ADR-0059 citations on the attribution paragraph. Residual staleness the first sweep missed: - docs/tutorials/cli/start-a-chat-session.md still taught "one agent and one model for its whole lifetime (no mid-session switching in Phase 1)". - home-controller.ts's stopReason doc-comment claimed the in-Home live reseat was an unshipped follow-up. It ships (drive-home.tsx wires deps.reseatChat); what is actually true is narrower — 'reseat' is unreachable via stopReason on the Home path because the Home reseat is driven by the wired dep, not a signal. The only surviving "one agent and one model" line in the tree is ADR-0059's own Context, which correctly describes the world before that decision. Left alone. Docs + one code comment; no behaviour change. Refs: ADR-0059, ADR-0066, ADR-0068 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Maintainer review of the draft. Folding every actionable finding: Honesty (the most important one): - "A money surface that cannot lie" was unqualified and therefore misleading. It cannot lie ABOUT ITS OWN EVENT STREAM. Against the provider's invoice Relavium is a systematic UNDER-estimate, and that is now said in the same breath rather than left to a Negative bullet. - Exceptions 1-2 (a succeeded attempt with no usage; a failed attempt) were framed as "known limits". They are not: they are a pre-existing usage-capture gap in the seam, which this ADR surfaces rather than causes. Reframed as such and FILED against 2.6.Q — the workstream that already re-opens the chain's cost folding — so they are tracked debt, not a shrug. The invariant holds either way: a seam that captures more usage grows both sides together. Rule 8 (drift): - Section 2's exact INSERT…ON CONFLICT and section 4's backfill DML were full, byte-exact SQL that would have had to stay identical to the migration and to database-schema.md forever — the drift rule 8 exists to prevent. Reduced to the conceptual shape; the statements belong to the migration. Section 1's CREATE TABLE stays: it carries the rationale (the raw-model-string key). Correctness / completeness: - Added CHECK (model <> ''). The event type forbids an empty model, but the DB is the system of record and an empty-string row would be a silent un-attributable money bucket. - The "historical price is immutable" clause called itself BINDING on an ADR that does not exist yet. It is a policy stance — proposed here, ADR-0071's to ratify — and it now says what a genuinely wrong price does: a symmetric, audited re-statement of BOTH sides, never a one-sided UPDATE. - The invariant's end-state test is necessary but NOT sufficient: it would still pass if the two writes were split into two transactions. The suite now also pins the mechanism (one transaction, asserted on the store seam) plus a crash-between-writes case. - loadSessionCosts' signature: ordered rows, empty array (never null). - Row id generation (deps.uuid()); the PK is never the conflict target. - Named the in-memory divergence two processes still have (the DB sum stays true; the live counters drift) so no surface silently relies on memory. Citations: - resolveModelCatalogId -> the resolver makeCatalogIdResolver builds. - "empty in-memory map" described a structure that does not exist today. - "the pinned SQLite" — the ADRs pin the driver and the Node floor, not an engine version; the DROP COLUMN floor is now explicit. - #emitSuccess line range off by one; "Considered and rejected" matches the corpus form. - Related was a 1516-character wall; split into primary / secondary / canonical homes / forward. Also: 2.6.H now carries the back-reference for run_costs.model_id — the workflow-side twin of the dead column this ADR closes on the session side. Status: Proposed -> Accepted. Refs: ADR-0070 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d to full-screen Step 2 of 2.6.C — the seam the reseat fix needs. No behaviour change yet: both production seed sites pass an empty carry; Steps 3 and 4 thread the real one. The bug this is the foundation of (F1): a /models reseat swaps in a brand-new view store whose transcript was hardcoded `[]`. On the full-screen renderer — whose viewport windows the store's IN-MEMORY transcript, and whose alt buffer has no native scrollback — the entire conversation vanishes from the screen. Alt-screen is the TTY default since 2.6.F, so this is the default surface. The durable transcript and the model's context were never at risk; only the view. - SessionViewSeed gains `transcript`, and it is REQUIRED. Deliberately: a construction site that forgets it should be a compile error, not another silently blank screen. (It caught both production sites immediately.) - initialSessionViewState seeds from it instead of hardcoding `[]`. - carriesSeedTranscript(bound) is the ONE gate, placed where the seed is CONSUMED rather than threaded through five createChatStore call sites — a single missed site would silently double-print. The gate matters in both directions: inline must NOT seed, because `driveInk` re-mounts ink per session and that resets <Static>'s index, so a seeded inline store would RE-PRINT the whole conversation on top of the copy already in the terminal's scrollback. Inline needs no carry anyway — its lines survive the swap. - An unknown bound degrades to inline: a wrong `true` doubles the screen, a wrong `false` is merely today's behaviour. - ChatView gains a dev/test tripwire. "Am I alt?" is now derived twice — the render layer reads `viewport`, the store layer reads `transcriptBound`. If those ever diverge, the tripwire fails a test instead of corrupting output. - The seed's doc comment pins the entries as VIEW-ONLY: they are the already sanitized render projection (no key, no ADR-0030 reasoning signature), and nothing serializes them — not the ADR-0026 export, not --json, not a dump. Break-verified three ways, each failing the tests it should: - restore the original `transcript: []` -> the full-screen carry test fails; - remove the gate (seed inline too) -> the inline + unknown-bound tests fail; - invert the gate -> all four gate tests fail. pnpm turbo run lint typecheck test: green. Refs: ADR-0059, ADR-0068 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lf-blind, and untested An aggressive review of c5a88d6 found the guard I shipped was wrong in three ways at once. Every finding was verified against source and is folded here. The tripwire was ARMED IN PRODUCTION. Nothing in this CLI ever sets NODE_ENV, so `process.env['NODE_ENV'] !== 'production'` is TRUE in a shipped session — the exact opposite of the comment above it — and apps/cli has no React error boundary, so the throw would have taken a user's chat down. It was also user-controllable in both directions: a shell exporting NODE_ENV=production would have silently disarmed the assertion the commit relied on. It now gates on VITEST: its job is to fail a future refactor in CI, and it has no business running in a terminal. The tripwire was HALF THE INVARIANT — and the missing half was F1 itself. It checked only inline-render + full-screen-store (the double-print). It did NOT check full-screen-render + inline-store, which is precisely this workstream's bug: the carry is silently dropped and the alt screen blanks. The commit said "assert they agree"; the code asserted one side. It now asserts the equality. The tripwire was DECORATIVE — no test could reach it. Every mounted test built its store with createChatStore(false), which fell through to the INLINE default bound — INCLUDING every alt-screen test. So the guard's second conjunct was never satisfiable, and deleting or inverting it left the suite green. Worse, that means the alt-screen mounted tests were never faithful to production, which builds those stores with transcriptBoundFor(true). Making the bound explicit turned 34 of them red on the spot; they are now built the way production builds them. And the root design was wrong. Making `transcript` required did NOT close the hole it claimed to: the seed PARAMETER is optional, so three call sites pass `undefined` and compile unchanged. The dangerous axis was the BOUND — a bare `number` with a silent inline default at three layers, so a future full-screen caller that forgot the argument would get no error and a blank screen: F1, with a green build. TranscriptBound is now a CLOSED union with no defaults anywhere, which makes both "forgot the bound" and "invented a third bound" compile errors. (Lint then caught that the union was not actually closed — Number.MAX_SAFE_INTEGER is typed `number` in lib.d.ts, so it widened the union straight back. The full-screen bound is now a literal, pinned to MAX_SAFE_INTEGER by a test.) Also: - The seed's comment claimed THREE callers reach seedResumedWiring including /clear. There are TWO. /clear is assembled by buildFreshChatWiring, so the warning that protects it now lives on the line that actually protects it — the `undefined` seed — with a regression test. - The invariant moved into a pure exported assertRenderStoreAgree, because a render-time throw does not escape React synchronously: expect(render).toThrow() proves nothing. The pure function is directly tested in both directions; the mounted suites prove the wiring by staying green. - Filed the chat-resume empty-viewport gap in deferred-tasks.md — found while scoping F1, deliberately out of scope, tracked rather than lost. Break-verified: making the assertion one-directional again fails the F1-direction test. pnpm turbo run lint typecheck test build: 24/24 green, 2108 tests. Refs: ADR-0059, ADR-0068 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…throw, so the tripwire could never fail
The previous fold moved the guard into ChatView and claimed "the mounted suites
prove the wiring by staying green". A second review disproved that, with a probe,
and I reproduced it: ink builds its React root with NO-OP error callbacks
(`reconciler.createContainer(…, () => {}, () => {}, …)`), so a throw from a
component is SWALLOWED. React tears the tree down, the frame goes empty, and the
test PASSES. Green proved nothing. The guard was unfailable.
Worse, my own fixture rewrite had already created the divergence it was supposed
to reject — and hidden it. Two mounted Home tests paired an INLINE Home (no
`alternateScreen`) with a FULL-SCREEN store. The tripwire threw, ink ate it, the
tree died, and the tests passed VACUOUSLY: `the INLINE Home never captures`
asserted over a chat that was no longer rendered. Probed and confirmed
(`PROBE fs=false carries=true` — the divergent pair — with a green suite).
The fix is to assert where a throw actually escapes:
- `driveInk`, right after the render mode resolves and BEFORE `render()` — the
standalone chat's composition root, ordinary async code.
- `drive-home`'s two store constructions, where the bound and the renderer both
derive from `altScreenActive`. They agree by construction; this pins that, so
giving them separate sources later fails loudly.
- The mounted-test harnesses (`mountHome`, `mountChat`, the alt-screen `chatApp`
helper), which now refuse a fixture production could not build.
The ChatView render-time call is gone: it was provably incapable of failing.
Also folded:
- Fixed the two vacuous fixtures to the pairing production builds (an inline Home
gets an inline-bound store).
- INLINE_TRANSCRIPT_BOUND no longer aliases MAX_LIVE_TOKEN_CHARS. They are equal
today by coincidence, not by relationship: one is a durable bake bound on a
completed entry, the other a per-frame render budget for the live token region.
Aliasing them welded a render budget to a renderer identity — a tweak to the
live budget would have silently redefined what an INLINE bound *is*, and
MAX_LIVE_TOKEN_CHARS itself type-checked as a TranscriptBound. A test pins the
current equality so the byte-identical inline path still cannot drift.
- Corrected the comments that asserted the disproved claim.
- Removed a stray probe test file a review agent left in the tree.
Break-verified: restoring either vacuous fixture now FAILS its test with a message
naming which side diverged. Before this commit the same fixture passed silently.
pnpm turbo run lint typecheck test build: 24/24 green, 2113 tests.
Refs: ADR-0059, ADR-0068
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eat (F1) Step 3 of 2.6.C — the Home path. This is where F1 actually stops happening. A mid-session /models reseat builds a brand-new view store. On the full-screen renderer that store IS the scrollback (the viewport windows its in-memory transcript, and the alt buffer has none of its own), so seeding it `[]` blanked the entire conversation the moment the user switched models. The durable row and the new model's context were always intact — the loss was the view, and alt-screen has been the TTY default since 2.6.F. - HomeControllerDeps.reseatChat now takes the outgoing store's rendered transcript. It is captured at the CALL — before the build resolves and the old session is torn down. (The teardown only closes the persister and MCP, never the view store, but capturing at the call makes the ordering explicit rather than incidental.) - drive-home's reseatChat seeds the new store with it. The Step-2 gate drops it on the inline renderer, where ink's <Static> already printed those lines and re-seeding would double-print them. Three tests, each proving a different layer, each break-verified: - drive-home.test.ts's REAL end-to-end reseat now asserts the rendered transcript. Its name has always said "carrying the transcript"; it only ever checked turnCount, model, sessionId and DB rows — never state.transcript. That gap is exactly how F1 shipped. Reverting the carry now fails it with `expected [ 'notice' ] to deeply equal [ 'user', 'assistant', 'notice' ]` — the bug's own signature: the switch notice alone, on an empty screen. - home-app.test.tsx gains a mounted regression whose reseat fake models what production DOES: a NEW store, seeded with the carry, at the full-screen bound. A fake handing back the SAME store object would keep the conversation on screen for free and could never fail against the un-fixed code — the shape of false confidence that let F1 past the existing tests. - home-controller.test.ts now asserts the carry reaches the dep, and its session fixture was given real turns (an empty store made the assertion vacuous). pnpm turbo run lint typecheck test build: 24/24 green. Refs: ADR-0059, ADR-0068 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eseat (F1) Step 4 of 2.6.C — the standalone `relavium chat` path. With Step 3 (the Home), F1 is now fixed on both surfaces. The carry is threaded through the five-hop reseat chain: runReplLoop reads the outgoing store's rendered transcript (the old session is torn down by then, but teardown closes the persister and MCP — never the view store), and it flows through resolveSwapRebuild -> createReseatRebuild -> buildReseatWiring -> seedResumedWiring into the reseated store's seed. `/clear` shares that loop and deliberately does NOT carry: it re-drives through `rebuild`, whose signature has no carry parameter at all. Its freshness is structural, not conventional — a future edit would have to add the parameter to break it. A regression test pins the outcome anyway. Three tests, break-verified: - ALT: the reseated store opens with the prior conversation. Cutting the carry makes it `[]` again — the bug, exactly as the user sees it. - INLINE: the same reseat seeds NOTHING, and this is the only surface where that matters. `driveInk` unmounts and re-mounts ink per session, which resets ink <Static>'s index to 0, so a seeded inline store would RE-PRINT the whole conversation over the copy the terminal's scrollback already holds. Removing the gate fails this test. (The Home never re-mounts RootApp across a swap, so <Static>'s index survives there and a stray carry would be silently harmless — which is why this negative belongs here and would prove nothing on the Home.) - /clear stays empty even on the full-screen renderer, where the carry WOULD apply if it were passed. pnpm turbo run lint typecheck test build: 24/24 green. Refs: ADR-0059, ADR-0068 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… it claimed
The mounted F1 test called itself the proof of "what the USER sees" and then
asserted only store state — never a frame. It duplicated what the gate unit tests
and home-controller.test.ts already pin, and would have stayed green if the
alt-screen viewport had failed to repaint from the swapped store at all: the one
thing only a mounted test can prove, and the exact F1 symptom.
It now reads the frame after the swap. Break-verified: cutting the carry fails it
with `expected '⇄ Switched to claude-opus-4-8 — 0 pri…' to contain 'what is 2+2'`
— the user's blank screen, rendered.
The switch-marker assertion was also vacuous. The fixture's own tail was a
`notice`, so `at(-1).role === 'notice'` held even with the marker never appended
(and `text.includes('four')` was satisfied by that same fixture entry). The
conversation now ends on a non-notice turn, and the assertion checks the marker's
identity — the new model's id — not just its role. Break-verified: deleting the
marker now fails it.
Three comments were lying about the code:
- createChatStore's JSDoc still documented the `= INLINE_TRANSCRIPT_BOUND`
default — the very default whose removal closes F1 from the caller's side. A
maintainer trusting it could have "restored" it and silently re-armed the bug.
Same lie on seedResumedWiring's bound param.
- The tripwire suite's docblock claimed ChatView calls assertRenderStoreAgree. It
does not (that call was removed when ink was found to swallow it); the guards
are the composition roots. It now names them.
pnpm turbo run lint typecheck test build: 24/24 green.
Refs: ADR-0059, ADR-0068
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d the ADR notes
Steps 5 and 6 of 2.6.C. F1's user-facing shape, and the harness ADR-0059 claimed
already existed.
THE MARKER. The notice was an INTRO LINE because until now the reseated view
opened on a blank screen: it announced the new model and told you what to do
next. With the conversation carried across the swap it interrupts a chat you can
still see, so it now says what actually changed, beneath it:
⇄ model changed claude-sonnet-4-6 → claude-opus-4-8 — the new model sees the
text transcript only (not prior tool calls or file contents).
The turn count is gone (the turns are visibly there) and so is the "type a
message, or /exit to quit" tail (an intro's job on an empty screen). What did NOT
go is the disclosure clause: ADR-0059's Decision and Consequences both rest on
it, so dropping it would be a superseding-ADR change, not a wording change. A
test pins it so a future tidy-up cannot quietly delete it. Both model ids stay
sanitizeInline-guarded — a live-catalog id is provider-sourced and history.db is
shared across surfaces.
THE PERF HARNESS. ADR-0059's Consequences state the reseat's O(n) cost is
"verified by the 2.6.C harness (a 200-message session reseats in well under the
interactive budget)". No such harness existed. It does now — and the carry makes
it more load-bearing, not less: a standalone reseat re-mounts ink and re-wraps
the entire carried transcript. It pins the budget AND the shape (linear, not
quadratic), because if this ever gets slow the answer is a wrap cache, not a
trim: ADR-0068 (c) makes the full-screen bound unbounded on purpose, and trimming
would re-introduce the clipping that ADR exists to fix. A 200-message reseat
seeds in ~0ms.
THE ADR NOTES (append-only, dated):
- ADR-0068 gains the limitation its own defaults created: DEFAULT_ALT_SCREEN=true
plus Decision (c)'s move of the transcript into the in-memory viewport turned
the ADR-0059 reseat into a visible loss of the rendered conversation. Its
known-limitations list had recorded only the lesser raw-io notice losses.
- ADR-0059 gains three corrections: "carries the text-only transcript" had become
true of the model's context but not the rendered view; the notice is now an
inline marker (with the bound disclosure retained); and the harness it claimed
existed did not.
chat-session.md now documents the shipped behaviour — the carry, the marker, the
inline no-carry (a seeded inline store would double-print), and /clear's fresh
start.
pnpm turbo run lint typecheck test build: 24/24 green.
Refs: ADR-0059, ADR-0066, ADR-0068
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ter, not the reseat
A review of the complete F1 fix (Steps 1-6) found the Step-6 harness was worse
than useless, and it was right.
THE HARNESS COULD NOT FAIL. It timed createChatStore — whose seed path is a
single reference assignment (initialSessionViewState ALIASES the carried array;
it never copies or iterates it). So the timed region was O(1) at any conversation
length, and `expect(elapsed).toBeLessThan(250)` was a tautology. Neither cost its
own docstring claimed to guard was inside the measurement. And I had amended
ADR-0059 to say this harness VERIFIES its O(n) claim — certifying a pointer
assignment. That is strictly worse than citing nothing.
It now measures the work the carry actually adds: `wrapTranscript` over the
carried transcript, which is what a fresh ink mount re-does from scratch on a
standalone reseat (the wrap memo is keyed on the transcript reference, which a
brand-new store always busts). It takes a median over samples instead of a single
GC-noisy reading. Break-verified: injecting an accidental O(n^2) into
wrapTranscript now fails it (13.6x vs the 10x ceiling). The old harness could not
have noticed.
THE MARKER'S ARGUMENTS WERE UNPINNED ON THE HOME PATH. `toContain('opus')` passes
a REVERSED marker just as happily, and no test on the Home surface asserted the
order. Worse, the flagship mounted test's outgoing store never set a model, so it
rendered `⇄ model changed (unknown) → …` and passed anyway — proving its
assertions could not see the marker's content at all. The fixture now carries its
model (as production does), and both the mounted test and drive-home.test.ts (the
real Home builder) assert the full `sonnet → opus` ordering plus ADR-0059's bound
disclosure clause. Break-verified: swapping the arguments now fails both.
Also: the ADR-0068 amendment was spliced in out of date order in a strictly
append-only log (07-09, 07-12, 07-10). Moved to the tail.
pnpm turbo run lint typecheck test build: 24/24 green.
Refs: ADR-0059, ADR-0068
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(ADR-0070)
Steps 7-8 of 2.6.C — the substrate the /cost breakdown reads. It exists because a
session_messages row holds exactly ONE model_id, while a single turn can bill TWO
models: the tool loop makes a fresh fallback-chain call per iteration, so
iteration 1 can succeed on model A and iteration 2 fail over to B. A per-message
cost column cannot represent that turn without lying.
THE INVARIANT (ADR-0070 §3), true by construction for every session:
SUM(session_costs.cost_microcents) == agent_sessions.total_cost_microcents
Both sides are fed by the SAME cost:updated egress, with the same arithmetic, in
the SAME transaction, from a single owner — across resume, reseat, failover, tool
loops, compaction, errored turns and aborted turns. /cost is a money surface: a
breakdown whose rows do not sum to the total shown is worse than no breakdown.
- session_costs, keyed on the RAW provider model string, NOT the catalog FK. The
FK resolves to NULL for an uncataloged model, and NULLs are DISTINCT under
UNIQUE in both dialects — so keying on it would make ON CONFLICT never match:
every uncataloged egress would insert a new row, and two different uncataloged
models would collapse into one bucket. A reviewer must not "fix" this back
toward run_costs.
- SINGLE-OWNER WRITES. total_cost_microcents is removed from updateSession's SET
payload. It used to be written blindly from whatever cumulative the caller held
— from four persister call sites and chat-export — so a stale writer (two
chat-resume processes on one sessionId; a late flush after a cost write) could
permanently break the sum. That was a live bug, not a hypothetical.
- The persister folds each cost:updated on the EVENT, NOT inside the
completed-turn gate. That gate wraps the message and token writes, but the cost
is real even for a failed or aborted turn (the engine never decrements it), and
a /compact whose summariser billed and then failed emits no terminal at all —
its spend would sit unflushed forever.
- cost:updated gains an additive optional `priced` (ADR-0070 §6). Without it,
cost 0 + real tokens is ambiguous between "could not price" and "genuinely
free", and a free-LOOKING row in the breakdown would be a lie. The row records
it as an unpriced_calls COUNTER, not a boolean, because 2.6.Q can price a model
mid-session — this is what lets ADR-0071 land without re-opening the schema.
- The migration DROPS session_messages' input_tokens/output_tokens/cost_microcents.
The persister never passed them, so every shipped row held 0; keeping them would
ship a second, wrong, zero-valued cost source beside the canonical one. It also
backfills a `(pre-2.6.C)` sentinel row per legacy session, so the invariant is
true for EVERY row in the table — not just post-migration ones. An invariant
with a silent exception class is the half-truth ADR-0070 exists to eliminate.
Break-verified, three ways, each failing exactly what it should:
- absolute instead of additive writes -> the fold + mixed-session tests fail;
- the total bump removed -> `expected +0 to be 1000` (rows without
their total: the divergence itself);
- total_cost_microcents restored to updateSession -> the single-owner test fails.
pnpm turbo run lint typecheck test build: 24/24 green.
Refs: ADR-0070, ADR-0059, ADR-0028
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Step 9 of 2.6.C — the surface. `/cost` was one line: the session total. It now shows where the money went. It reads the DB, never the in-memory store. A RESUMED session's total is seeded from the durable row and covers the whole session, while memory knows only the models used in THIS process — so an in-memory breakdown would visibly fail to sum to the total on the very first /cost after a resume, breaking the one guarantee the panel makes. The rows are GUARANTEED to sum to the total, because `SUM(session_costs) == agent_sessions.total_cost_microcents` holds by construction (ADR-0070 §3). Two honesties the panel carries: - A $0 row with real tokens is a model we could not PRICE, not a free one. It says "price unknown for N of M calls". Without that, an unpriced model reads as a free model — and 2.6.Q's whole F5 finding is that unpriced models are exactly where the cost cap silently stops applying. - A session older than 2.6.C says its breakdown is unavailable, rather than rendering its backfilled sentinel row as if it had used a single model. The guarantee is against Relavium's own event stream, not the provider's invoice: an egress that streamed content but ended without a usage chunk, and a mid-stream failure, are recorded as zero on BOTH sides. The reported spend is therefore a systematic under-estimate of the bill. That is stated in the code, in chat-session.md, and in database-schema.md — not buried. The bare Home's /cost stays inert, correctly: there is no live session to cost. (The in-Home CHAT reaches the same implementation through createChatLineHandler, so both surfaces get the breakdown.) Now that the read needs only a sessionId, costing a PAST session is 2.6.G's session browser away. Canonical homes updated: database-schema.md gains session_costs (and records that session_messages' never-written token/cost columns were dropped); sse-event-schema.md documents the additive `priced` field; chat-session.md's /cost row and reseat section now describe what ships. pnpm turbo run lint typecheck test build: 24/24 green. Refs: ADR-0070 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e reseat fold Two hazards I flagged for the review and then verified myself. The row id diverged from the ADR. I had built it deterministically as `sessionId + NUL + model`, but ADR-0070 says it comes from the host's uuid() — the pattern every other store write uses. The ADR is Accepted, so the code follows it, not the other way round. It also removes the question the deterministic form invites (what if a model string contained a NUL). Either way the id is DISCARDED on the common path: the conflict target is the (session_id, model) unique index, never the PK. Two probes became permanent tests, because both guard a real way the invariant could have broken: - A RESEAT keeps the SAME sessionId but builds a FRESH persister whose accumulators start at zero. An absolute write would have zeroed every model row the prior process committed; the additive fold means 400 + 600 = 1000, not 600. - Two sessions on the SAME model must not collide. The key is (session, model), not the model. pnpm turbo run lint typecheck test build: 24/24 green. Refs: ADR-0070 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e panel could print >100%
A review of the whole /cost work found the step's central change had no coverage
at any layer, and three ways the panel could lie. All verified, all folded.
THE DB READ WAS UNTESTED. `showCost` was reached by no test. The one end-to-end
/cost case ran it as the FIRST input with zero spend and zero rows, so both the
old in-memory total and the new DB read emit the byte-identical
`Session cost: $0.0000` — reverting the handler to memory left the suite green.
The RESUME case, which is the entire reason the DB read exists, was unexercised.
It is now: a second process resumes a session it did not spend in and asks for
/cost before spending anything. Break-verified — reverting to memory now fails
with `expected 'Session cost: $0.0001' to match /\(\d+ calls?, \d+ tok\)/`: the
one-line total, no breakdown.
THE PANEL COULD PRINT ROWS SUMMING TO MORE THAN ITS OWN TOTAL. The guarantee
("the rows sum to the total") was read across TWO independent statements. history.db
runs in WAL, so a reader is never blocked by a writer, and another process on the
same session — the very concurrency the single-writer design defends against — can
commit a cost write BETWEEN the loadSession and the loadSessionCosts. The share
column would then exceed 100% on a money surface. The DB invariant held; the READ
broke it. There is now one snapshot read, in one transaction.
THE LEGACY SENTINEL BECAME A LIE THE MOMENT A LEGACY SESSION SPENT AGAIN. The
"breakdown unavailable" case was gated on `rows.length === 1`, but the backfilled
sentinel carries call_count 0 and tokens 0 while parking the whole legacy total in
cost_microcents. As soon as one real model row landed — i.e. the moment a user
RESUMES a pre-2.6.C session and sends a message, the likeliest way anyone meets
this table — the sentinel fell through the normal path and printed
`$0.0420 40% before per-model attribution (0 calls, 0 tok)`: real money, zero
calls, zero tokens, with the disclosure gone from the panel entirely. It is now
handled PER ROW and never claims counts it structurally cannot have.
Also:
- /cost had no try/catch. It is a read-only info command, and every sibling is
guarded — but a throw from it routes to the ink driver's onError and TEARS THE
SESSION DOWN. A SQLITE_IOERR on a /cost must not end the user's conversation.
Reported by CliError CODE, never a raw message (a DB error can carry a path).
- The `(pre-2.6.C)` sentinel literal was duplicated across the package boundary
with nothing enforcing the match — rule 8 applied to a value. It now has one
home in @relavium/db, beside the migration that writes it.
- costNotice's `rows` defaulted to `[]`, so a future caller that forgot the
breakdown would silently degrade to the old one-line total with no type error —
exactly the regression this step exists to remove. Now required.
pnpm turbo run lint typecheck test build: 24/24 green.
Refs: ADR-0070
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both tasks shipped, plus a residual that turned out to need its own ADR. - The reseat transcript-carry (F1) and the switch marker. - /cost's per-model breakdown, behind ADR-0070 — because ADR-0059's plan to derive it from the per-message model_id cannot work: one row holds one model, but a turn whose tool loop fails over bills two. Recorded honestly, including what the four review rounds caught in my own work and the two obligations carried out: chat-resume's empty viewport (never a regression — it has never repainted history) and the seam's usage-capture gap that makes reported spend a systematic under-estimate of the provider's invoice (filed against 2.6.Q). Refs: ADR-0070, ADR-0059, ADR-0068 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Sorry @cemililik, your pull request is larger than the review limit of 150000 diff characters
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis change adds durable per-model session cost attribution, database-backed ChangesSession costs and renderer-aware reseat
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant ChatREPL
participant SessionPersister
participant SessionStore
participant Renderer
User->>ChatREPL: /models or /cost
ChatREPL->>Renderer: capture rendered transcript for reseat
ChatREPL->>SessionPersister: process model reseat and cost events
SessionPersister->>SessionStore: persist per-model cost attribution
ChatREPL->>Renderer: seed full-screen transcript or render cost breakdown
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Code Review
This pull request implements durable per-model session cost attribution (ADR-0070) by introducing a new session_costs table and dropping unused token and cost columns from session_messages. It updates the /cost command to read the breakdown from the database, ensuring accuracy across resumed sessions, and adds extensive tests for the per-model cost breakdown and the transcript carry on alt-screen. The review feedback correctly identifies critical transaction isolation issues in packages/db/src/session-store.ts, where database operations inside db.transaction callbacks are incorrectly executed on the outer db instance instead of the transaction context parameter.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| withBusyRetry(() => | ||
| db.transaction( | ||
| () => { | ||
| db.insert(sessionCosts) | ||
| .values({ | ||
| id: entry.id, // discarded on conflict — the target is the (session_id, model) unique index | ||
| sessionId: entry.sessionId, | ||
| model: entry.model, | ||
| modelCatalogId: entry.modelCatalogId ?? null, | ||
| inputTokens: entry.inputTokens, | ||
| outputTokens: entry.outputTokens, | ||
| costMicrocents: entry.costMicrocents, | ||
| callCount: 1, | ||
| unpricedCalls: entry.priced ? 0 : 1, | ||
| createdAt: entry.ts, | ||
| updatedAt: entry.ts, | ||
| }) | ||
| .onConflictDoUpdate({ | ||
| target: [sessionCosts.sessionId, sessionCosts.model], | ||
| set: { | ||
| inputTokens: sql`${sessionCosts.inputTokens} + ${entry.inputTokens}`, | ||
| outputTokens: sql`${sessionCosts.outputTokens} + ${entry.outputTokens}`, | ||
| costMicrocents: sql`${sessionCosts.costMicrocents} + ${entry.costMicrocents}`, | ||
| callCount: sql`${sessionCosts.callCount} + 1`, | ||
| unpricedCalls: sql`${sessionCosts.unpricedCalls} + ${entry.priced ? 0 : 1}`, | ||
| updatedAt: entry.ts, | ||
| }, | ||
| }) | ||
| .run(); | ||
| db.update(agentSessions) | ||
| .set({ | ||
| totalCostMicrocents: sql`${agentSessions.totalCostMicrocents} + ${entry.costMicrocents}`, | ||
| updatedAt: entry.ts, | ||
| }) | ||
| .where(eq(agentSessions.id, entry.sessionId)) | ||
| .run(); | ||
| }, | ||
| { behavior: 'immediate' }, | ||
| ), |
There was a problem hiding this comment.
In Drizzle ORM, queries executed inside a transaction must use the transaction context parameter (usually named 'tx') passed to the callback. Executing queries on the outer 'db' instance runs them outside the transaction, which breaks the atomicity and isolation guarantees of the transaction. Use the 'tx' parameter to execute the insert and update queries within the transaction.
withBusyRetry(() =>
db.transaction(
(tx) => {
tx.insert(sessionCosts)
.values({
id: entry.id,
sessionId: entry.sessionId,
model: entry.model,
modelCatalogId: entry.modelCatalogId ?? null,
inputTokens: entry.inputTokens,
outputTokens: entry.outputTokens,
costMicrocents: entry.costMicrocents,
callCount: 1,
unpricedCalls: entry.priced ? 0 : 1,
createdAt: entry.ts,
updatedAt: entry.ts,
})
.onConflictDoUpdate({
target: [sessionCosts.sessionId, sessionCosts.model],
set: {
inputTokens: sql.raw("input_tokens + " + entry.inputTokens),
outputTokens: sql.raw("output_tokens + " + entry.outputTokens),
costMicrocents: sql.raw("cost_microcents + " + entry.costMicrocents),
callCount: sql.raw("call_count + 1"),
unpricedCalls: sql.raw("unpriced_calls + " + (entry.priced ? 0 : 1)),
updatedAt: entry.ts,
},
})
.run();
tx.update(agentSessions)
.set({
totalCostMicrocents: sql.raw("total_cost_microcents + " + entry.costMicrocents),
updatedAt: entry.ts,
})
.where(eq(agentSessions.id, entry.sessionId))
.run();
},
{ behavior: 'immediate' },
),
);| withBusyRetry(() => | ||
| db.transaction(() => ({ | ||
| totalCostMicrocents: loadSession(sessionId)?.totalCostMicrocents ?? 0, | ||
| rows: readCostRows(sessionId), | ||
| })), | ||
| ), |
There was a problem hiding this comment.
Inside 'loadSessionCostBreakdown', the queries 'loadSession' and 'readCostRows' are executed on the outer 'db' instance instead of the transaction context 'tx'. This defeats the snapshot isolation guarantee of the transaction. Query the database directly using 'tx' to ensure both reads occur within the same transaction snapshot. Additionally, when constructing the returned rows, use the conditional spread pattern for the optional 'modelCatalogId' property to maintain codebase consistency and avoid setting explicit 'undefined' values under 'exactOptionalPropertyTypes'.
withBusyRetry(() =>
db.transaction((tx) => {
const row = tx
.select()
.from(agentSessions)
.where(eq(agentSessions.id, sessionId))
.get();
const totalCostMicrocents = row?.totalCostMicrocents ?? 0;
const rows = tx
.select()
.from(sessionCosts)
.where(eq(sessionCosts.sessionId, sessionId))
.orderBy(desc(sessionCosts.costMicrocents))
.all()
.map((r) => ({
model: r.model,
...(r.modelCatalogId === null || r.modelCatalogId === undefined ? {} : { modelCatalogId: r.modelCatalogId }),
inputTokens: r.inputTokens,
outputTokens: r.outputTokens,
costMicrocents: r.costMicrocents,
callCount: r.callCount,
unpricedCalls: r.unpricedCalls,
}));
return { totalCostMicrocents, rows };
}),
)References
- Prefer the conditional spread pattern
...(x === undefined ? {} : { x })(or withnullchecks) for optional properties in TypeScript to maintain codebase consistency and conciseness. Avoid replacing it with verbose helper objects with multipleifstatements or lateral rest-destructuring style changes, as the conditional spread is correct underexactOptionalPropertyTypesand prevents explicitundefinedvalues from reaching the target.
There was a problem hiding this comment.
Actionable comments posted: 8
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/cli/src/render/tui/chat-projection.test.ts`:
- Around line 1097-1117: Update the performance test around medianWrapMs so each
benchmark sample measures cold wrapping by supplying fresh TranscriptEntry
objects, or otherwise clear entryWrapCache before every sample. Keep the
existing 200-message assertion and budget check, and adjust the warm-up/comment
wording only if the test intentionally measures steady-state cached latency
instead.
In `@apps/cli/src/render/tui/home-app.test.tsx`:
- Around line 940-945: Update the assertions in the conversation-switch test
around the seeded `store.notice('assistant: four')` entry to verify that the
carried assistant output is present and preserved, rather than only checking
user messages and the switch marker. Apply the same assertion change to the
corresponding test block near the second referenced section.
In `@docs/analysis/models-dev-dynamic-catalog-enrichment-2026-07-11.md`:
- Around line 179-181: Specify the fenced block language for the precedence
string by labeling the Markdown fence as text, preserving the existing content
unchanged.
In `@docs/decisions/0070-durable-per-model-session-cost-attribution.md`:
- Line 71: Update the documented recordSessionCost signature to include the
required id field alongside sessionId and the other entry properties. Keep the
signature consistent with SessionStore.recordSessionCost and the ADR’s statement
that row IDs are generated via deps.uuid().
In `@docs/reference/contracts/sse-event-schema.md`:
- Line 81: Add the optional priced?: boolean property to the CostUpdatedEvent
interface alongside the existing cost and token fields, matching the documented
cost:updated event shape and preserving backward compatibility for readers and
producers that omit it.
In `@docs/reference/desktop/database-schema.md`:
- Around line 392-398: Update the later mapping note around the per-message
column list to remove input_tokens, output_tokens, and cost_microcents, leaving
only columns that still exist such as model_id. Direct durable cost attribution
to session_costs, consistent with the migration 0009 documentation.
In `@docs/roadmap/phases/phase-2.6-conversational-authoring.md`:
- Around line 220-222: Update the blockquote around the “Status” note by
prefixing the blank line between the consecutive quoted lines with “>” or
merging the notes, preserving the content while making the blockquote continuous
and satisfying MD028.
In `@docs/tutorials/cli/start-a-chat-session.md`:
- Around line 51-55: Update the model-switch sentence in the tutorial text to
use the complete English phrasing “The model can be changed mid-chat with
/models,” while preserving the surrounding explanation and formatting.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9ee68cbe-fbec-48e1-92d7-208101018ebd
📒 Files selected for processing (48)
apps/cli/src/chat/persister.test.tsapps/cli/src/chat/persister.tsapps/cli/src/chat/repl-info.test.tsapps/cli/src/chat/repl-info.tsapps/cli/src/chat/session-host.test.tsapps/cli/src/commands/chat.test.tsapps/cli/src/commands/chat.tsapps/cli/src/home/drive-home.test.tsapps/cli/src/home/drive-home.tsxapps/cli/src/render/tui/chat-app.test.tsxapps/cli/src/render/tui/chat-ink.tsxapps/cli/src/render/tui/chat-projection.test.tsapps/cli/src/render/tui/chat-store.test.tsapps/cli/src/render/tui/chat-store.tsapps/cli/src/render/tui/home-app.test.tsxapps/cli/src/render/tui/home-controller.test.tsapps/cli/src/render/tui/home-controller.tsapps/cli/src/render/tui/session-view-model.test.tsapps/cli/src/render/tui/session-view-model.tsdocs/analysis/README.mddocs/analysis/models-dev-dynamic-catalog-enrichment-2026-07-11.mddocs/architecture/agent-sessions.mddocs/decisions/0059-cli-mid-session-model-reseat.mddocs/decisions/0068-full-screen-tui-renderer-ink7-harness.mddocs/decisions/0069-string-width-for-the-cli-renderer.mddocs/decisions/0070-durable-per-model-session-cost-attribution.mddocs/decisions/README.mddocs/glossary.mddocs/reference/cli/accessibility.mddocs/reference/cli/chat-session.mddocs/reference/cli/home.mddocs/reference/contracts/agent-session-spec.mddocs/reference/contracts/sse-event-schema.mddocs/reference/desktop/database-schema.mddocs/reference/vscode/extension-api.mddocs/roadmap/current.mddocs/roadmap/deferred-tasks.mddocs/roadmap/phases/phase-2.6-conversational-authoring.mddocs/tutorials/cli/start-a-chat-session.mdpackages/core/src/engine/agent-turn.tspackages/db/drizzle/0009_lonely_marauders.sqlpackages/db/drizzle/meta/0009_snapshot.jsonpackages/db/drizzle/meta/_journal.jsonpackages/db/src/index.tspackages/db/src/schema.tspackages/db/src/session-store.test.tspackages/db/src/session-store.tspackages/shared/src/run-event.ts
…st that measured its own cache, and five doc drifts The review found nine real things and one that does not apply. Verified each against the code. **The transaction handle (packages/db).** `recordSessionCost` and `loadSessionCostBreakdown` issued their statements against the closure's `db` from INSIDE the transaction callback, ignoring `tx`. Not a live bug — better-sqlite3 is a single connection, so they land on the same one, and the rollback test proves atomicity holds today. But the Postgres driver (ADR-0005) is a POOL: there `db` is a DIFFERENT connection and the two writes would silently commit outside the transaction, breaking `SUM(session_costs) == total_cost_microcents` on a money surface. Both now run on `tx`, with the read helpers taking an executor. `DbTx` is derived from `Db` so no drizzle-internal type is imported. **The perf harness measured its own cache (apps/cli).** `wrapTranscript` memoizes per entry in a WeakMap keyed by the entry object. The harness re-timed the SAME array five times, so every sample after the warm-up was a cache hit: the median reported steady-state lookup latency, and a regression inside the per-entry wrap was invisible to it. Each sample now builds fresh entries outside the timed region. Two-sided break-verification: with +0.8ms injected into `wrapEntry`, the old harness PASSED and the new one fails with `expected 160.41 to be less than 100` — it would have shipped a 160ms wrap as "well under the budget". **The reseat test never asserted the model's output (apps/cli).** It seeded `assistant: four` and then checked only the two `user` entries and the marker. A carry that kept `role === 'user'` and dropped everything else would have stayed green — the marker is appended after the swap, so it would still be on screen — while half the conversation vanished. Now asserted. **Docs.** ADR-0070's `recordSessionCost` signature omitted the required `id` it says is a `deps.uuid()`; `CostUpdatedEvent`'s TS interface omitted the `priced?` its own table row documents; database-schema's `SessionMessage` mapping note still listed `input_tokens`/`output_tokens`/`cost_microcents` as populatable metadata, contradicting the migration-0009 note eight lines above it; plus MD028 and a bare fence. **Not applied:** the conditional-spread suggestion for `modelCatalogId`. `SessionCostRow.modelCatalogId` is `readonly modelCatalogId: string | undefined` — a REQUIRED property that admits undefined, not a `?` optional. The spread pattern is for `?` props under exactOptionalPropertyTypes; omitting the key here is a type error. Also documents `initialSessionViewState` (the docstring-coverage gate), which is where the whole inline-vs-full-screen seed decision lives. Refs: ADR-0070, ADR-0059, ADR-0005 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
docs/decisions/0070-durable-per-model-session-cost-attribution.md (3)
52-53: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick winDefine
call_countconsistently as all cost events.The write-path description increments
call_countfor everycost:updated, including unpriced calls, while the schema comment says “billed egresses.” Since/costreports unpriced calls asN of M,Mmust include those calls. Update the contract wording to avoid inconsistent implementations.Also applies to: 71-75, 172-175
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/decisions/0070-durable-per-model-session-cost-attribution.md` around lines 52 - 53, Update the contract wording for call_count in the schema comment and the referenced write-path sections to define it as the total number of cost events, including unpriced calls. Ensure the /cost N-of-M semantics use call_count as the full event count, while unpriced_calls remains the subset that could not be priced.
132-139: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftUse a collision-proof legacy-session marker.
cost:updated.modelis a raw string and supports custom/self-hosted models; the event contract does not forbid parentheses. A real model named(pre-2.6.C)would collide with the backfill row’s(session_id, model)key and merge new spend into the “breakdown unavailable” bucket. Reserve the sentinel through schema validation or add an explicit legacy-row discriminator.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/decisions/0070-durable-per-model-session-cost-attribution.md` around lines 132 - 139, Revise the legacy backfill design described in the ADR to prevent collisions with custom model identifiers: reserve the legacy-session marker through schema validation, or add an explicit discriminator that separates legacy aggregate rows from real model rows. Update the invariant and /cost rendering description to use the selected collision-proof representation, and ensure new spend cannot merge into the legacy bucket.
41-67: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winRemove or complete the duplicated schema block.
This ADR identifies
database-schema.mdas the canonical schema home, but then restates the fullCREATE TABLEand indexes. The duplicate also omits theCHECK (model <> '')claimed at Line 67. Keep only the decision-level constraints and link to the canonical schema, or make the example complete and explicitly illustrative.As per coding guidelines, specifications must have one canonical home under
docs/reference/; link to them rather than restating them.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/decisions/0070-durable-per-model-session-cost-attribution.md` around lines 41 - 67, Remove the duplicated CREATE TABLE and index SQL block from the ADR, retaining only the decision-level constraints and rationale. Update the surrounding text to link to the canonical session_costs definition in database-schema.md, including the model non-empty CHECK constraint, without restating the full schema.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@docs/decisions/0070-durable-per-model-session-cost-attribution.md`:
- Around line 52-53: Update the contract wording for call_count in the schema
comment and the referenced write-path sections to define it as the total number
of cost events, including unpriced calls. Ensure the /cost N-of-M semantics use
call_count as the full event count, while unpriced_calls remains the subset that
could not be priced.
- Around line 132-139: Revise the legacy backfill design described in the ADR to
prevent collisions with custom model identifiers: reserve the legacy-session
marker through schema validation, or add an explicit discriminator that
separates legacy aggregate rows from real model rows. Update the invariant and
/cost rendering description to use the selected collision-proof representation,
and ensure new spend cannot merge into the legacy bucket.
- Around line 41-67: Remove the duplicated CREATE TABLE and index SQL block from
the ADR, retaining only the decision-level constraints and rationale. Update the
surrounding text to link to the canonical session_costs definition in
database-schema.md, including the model non-empty CHECK constraint, without
restating the full schema.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 7a804b4a-a839-4d2c-bdd7-51e7f860e77e
📒 Files selected for processing (10)
apps/cli/src/render/tui/chat-projection.test.tsapps/cli/src/render/tui/home-app.test.tsxapps/cli/src/render/tui/session-view-model.tsdocs/analysis/models-dev-dynamic-catalog-enrichment-2026-07-11.mddocs/decisions/0070-durable-per-model-session-cost-attribution.mddocs/reference/contracts/sse-event-schema.mddocs/reference/desktop/database-schema.mddocs/roadmap/phases/phase-2.6-conversational-authoring.mddocs/tutorials/cli/start-a-chat-session.mdpackages/db/src/session-store.ts
🚧 Files skipped from review as they are similar to previous changes (7)
- docs/tutorials/cli/start-a-chat-session.md
- docs/analysis/models-dev-dynamic-catalog-enrichment-2026-07-11.md
- apps/cli/src/render/tui/chat-projection.test.ts
- docs/reference/desktop/database-schema.md
- apps/cli/src/render/tui/session-view-model.ts
- docs/roadmap/phases/phase-2.6-conversational-authoring.md
- apps/cli/src/render/tui/home-app.test.tsx
…sentinel that could collide, and a migration that must not be re-cut
**CI was red on the test I had just "fixed".** Making the wrap perf harness sample COLD entries exposed that its
ratio was measured at a scale where the reading is mostly fixed overhead and GC: the same code read 2.4x–5.0x
run-to-run, and CI reported 23.9x. Two problems, both measured rather than reasoned about:
• a ratio of MEDIANS measures the runner's memory pressure — the 4x-larger workload eats the GC the smaller one
dodges. It is now a ratio of MINIMUMS: GC and preemption only ADD time, so the fastest sample is the least
contaminated estimate, and the ratio is independent of how fast the machine is.
• at a 4x scale factor the test had NO POWER. Injecting a real O(n^2) re-scan moved the ratio from 4.0 to 5.2 —
inside any threshold loose enough to be stable. It now compares 8x, where real code reads 4.60–4.81 across six
runs and the injected quadratic reads 16.62. The threshold sits between them.
My first attempt to break-verify this proved nothing: the injected loop had no side effect and V8 eliminated it, so
the test "passed" against unchanged code. The note in the test says so, for whoever breaks it next.
**The legacy sentinel could collide (review).** `(pre-2.6.C)` was the legacy row's IDENTITY, defended by "no provider
id contains parentheses" — which contradicts the reason `model` holds the raw provider string at all: a custom model
may be named anything. A user resuming a pre-2.6.C session with a model named `(pre-2.6.C)` would have had their real
spend upserted ONTO the legacy aggregate. `is_legacy` is now a column in the unique index; a real egress always writes
0 and cannot fold into it. The string is a label; nothing branches on it.
**And the migration must not be re-cut.** Regenerating 0009 with the new column broke the local database — because
`~/.relavium/history.db` (real sessions) had already applied 0009, and drizzle replays a migration whose journal
timestamp is new. CI would never have caught it: a fresh runner has no database. 0009 therefore stands as shipped and
0010 adds the column additively, verified against a COPY of the real 3.3 MB database: 24 legacy rows flagged, 2 real
rows untouched, `SUM(session_costs) == SUM(total_cost_microcents)` still exact.
**The backfill had zero coverage** — which is how a lost `--> statement-breakpoint` glued the INSERT onto a DROP
COLUMN, and every test stayed green. Two migration tests now run the real thing: a fresh install, and the 0009 → 0010
upgrade path.
Also folds the remaining doc findings: ADR-0070's duplicated CREATE TABLE (rule 8 — it said it would not restate the
schema, then restated it), `call_count` defined as every event with `unpriced_calls` a subset of it, and the 0009/0010
split recorded honestly in the ADR and the schema reference.
Refs: ADR-0070, ADR-0059, ADR-0005
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
docs/decisions/0070-durable-per-model-session-cost-attribution.md (3)
60-62: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winDocument the
is_legacydiscriminator in the upsert target.This section says the upsert targets
(session_id, model), but the schema and implementation use(session_id, model, is_legacy). Following this text would reintroduce the sentinel/custom-model collision that this ADR is intended to prevent.Suggested fix
-1. an **upsert** on `(session_id, model)` that +1. an **upsert** on `(session_id, model, is_legacy)` that🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/decisions/0070-durable-per-model-session-cost-attribution.md` around lines 60 - 62, Update the upsert target description in the ADR to include the is_legacy discriminator, specifying (session_id, model, is_legacy) rather than only (session_id, model). Preserve the additive update semantics and explain that the discriminator keeps sentinel and custom-model records distinct.
81-81: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick winDefine the invariant for zero-spend sessions.
A fresh session has no
session_costsrows, so SQLSUM(...)returnsNULL, not0. As written, the invariant is false for those sessions. State that an empty sum is zero or express it withCOALESCE(SUM(...), 0).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/decisions/0070-durable-per-model-session-cost-attribution.md` at line 81, Clarify the invariant for every agent_sessions row so sessions without session_costs entries treat the empty sum as zero. Update the displayed SUM expression to use COALESCE(SUM(...), 0), or explicitly state that an empty sum equals zero, while preserving the existing equality to total_cost_microcents.
167-172: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winDocument the transactional
/costread API.The store provides
loadSessionCostBreakdown(sessionId)to return the total and rows from one transaction, but this ADR names onlyloadSessionCosts(sessionId). That omits the API needed to prevent the panel from combining rows and totals from different snapshots.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/decisions/0070-durable-per-model-session-cost-attribution.md` around lines 167 - 172, Update the `/cost` read API description to name `SessionStore.loadSessionCostBreakdown(sessionId)` as the transactional source for the total and per-model rows, replacing or clarifying the standalone `loadSessionCosts(sessionId)` reference. State that the panel must obtain both values from this single transaction snapshot rather than combining separate reads.apps/cli/src/chat/repl-info.ts (1)
36-64: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winLabel tokens that include failed/aborted turns.
The ADR-0070
/costcontract requires token counts to be identified as including failed and aborted attempts. The current row output only saystok, which can make these counts look like completed-turn usage. Add the qualification to the row or panel header and assert it in the test.Suggested fix
- const tokens = `${r.inputTokens + r.outputTokens} tok`; + const tokens = `${r.inputTokens + r.outputTokens} tok (incl. failed/aborted turns)`;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/cli/src/chat/repl-info.ts` around lines 36 - 64, Update costNotice so the token-count display explicitly states that counts include failed and aborted attempts, preferably by qualifying the row’s existing “tok” label or the panel header without altering cost or attribution behavior. Anchor the change in costNotice’s non-legacy row formatting, and update the related test to assert the required qualification.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/db/src/session-store.test.ts`:
- Line 844: Update the SHIPPED migration directory constant in
session-store.test.ts to resolve from the test file’s directory rather than
process.cwd(), ensuring it targets packages/db/drizzle under repository-root
execution while preserving the existing migration test behavior.
---
Outside diff comments:
In `@apps/cli/src/chat/repl-info.ts`:
- Around line 36-64: Update costNotice so the token-count display explicitly
states that counts include failed and aborted attempts, preferably by qualifying
the row’s existing “tok” label or the panel header without altering cost or
attribution behavior. Anchor the change in costNotice’s non-legacy row
formatting, and update the related test to assert the required qualification.
In `@docs/decisions/0070-durable-per-model-session-cost-attribution.md`:
- Around line 60-62: Update the upsert target description in the ADR to include
the is_legacy discriminator, specifying (session_id, model, is_legacy) rather
than only (session_id, model). Preserve the additive update semantics and
explain that the discriminator keeps sentinel and custom-model records distinct.
- Line 81: Clarify the invariant for every agent_sessions row so sessions
without session_costs entries treat the empty sum as zero. Update the displayed
SUM expression to use COALESCE(SUM(...), 0), or explicitly state that an empty
sum equals zero, while preserving the existing equality to
total_cost_microcents.
- Around line 167-172: Update the `/cost` read API description to name
`SessionStore.loadSessionCostBreakdown(sessionId)` as the transactional source
for the total and per-model rows, replacing or clarifying the standalone
`loadSessionCosts(sessionId)` reference. State that the panel must obtain both
values from this single transaction snapshot rather than combining separate
reads.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 530fdd6e-13da-425b-acb9-f58daddde97a
📒 Files selected for processing (12)
apps/cli/src/chat/repl-info.test.tsapps/cli/src/chat/repl-info.tsapps/cli/src/render/tui/chat-projection.test.tsdocs/decisions/0070-durable-per-model-session-cost-attribution.mddocs/reference/desktop/database-schema.mdpackages/db/drizzle/0009_lonely_marauders.sqlpackages/db/drizzle/0010_clumsy_jocasta.sqlpackages/db/drizzle/meta/0010_snapshot.jsonpackages/db/drizzle/meta/_journal.jsonpackages/db/src/schema.tspackages/db/src/session-store.test.tspackages/db/src/session-store.ts
🚧 Files skipped from review as they are similar to previous changes (6)
- packages/db/drizzle/meta/_journal.json
- packages/db/src/schema.ts
- docs/reference/desktop/database-schema.md
- packages/db/drizzle/0009_lonely_marauders.sql
- apps/cli/src/render/tui/chat-projection.test.ts
- packages/db/src/session-store.ts
… — an @-attached file DOES carry Investigating "why aren't tool calls carried across a model switch" turned up a defect in the answer we were giving users. The notice — and ADR-0059's own Decision text — said the new model sees "the text transcript only (not prior tool calls **or file contents**)". The "file contents" half is false. A file attached with `@` is framed into the USER MESSAGE TEXT: `chat.ts` hands `persister.beginUserTurn(line)` the full framed line, not the compact display form. It is part of the durable transcript, and the reseat replays it. So an `@`-attached file DOES cross the switch — and the notice told the user it did not. That is the dangerous direction to be wrong in. It does not overstate a guarantee; it UNDERSTATES data exposure. A user who attached a secret and then switched models would have read our own notice as an assurance that the new model could not see it. What IS lost is a file a TOOL read, because that arrives as a `tool_result` — and the engine carries no tool pairs across ANY turn boundary (ADR-0062 §6: "Cross-turn tool-call/tool-result accumulation: does not exist in this design"), reseat or not. The notice now says exactly that, and both halves are pinned. This NARROWS the disclosure to what is true; the text-only bound ADR-0059 binds is unchanged, so this is a dated amendment note (§4), not a supersede. Refs: ADR-0059, ADR-0062 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
docs/decisions/0059-cli-mid-session-model-reseat.md (1)
105-122: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winRestore the amendment numbering order.
The note enumerates items as
1, 2, 4, 3. Renumber or reorder the entries to keep the amendment sequence unambiguous.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/decisions/0059-cli-mid-session-model-reseat.md` around lines 105 - 122, Restore sequential amendment ordering in the ADR correction note by changing the visible item labels from 4 and 3 to the appropriate 3 and 4 order, or reorder the entries accordingly. Keep each amendment’s content unchanged and ensure the sequence is unambiguous.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/decisions/0059-cli-mid-session-model-reseat.md`:
- Line 112: Update the ADR-0062 link in the boundary reference to use the
identified relative target filename,
0062-context-compaction-and-cli-history-commands.md, while preserving the
existing anchor and surrounding text.
- Around line 105-115: Append an explicit supersession statement to ADR 0059
that overrides the earlier claim that file contents are not carried across model
reseat. State that `@-attached` file contents are included in the durable
user-message transcript and are carried, while tool-read file contents are not
carried because tool pairs do not cross turn boundaries; leave the existing ADR
text unchanged.
---
Outside diff comments:
In `@docs/decisions/0059-cli-mid-session-model-reseat.md`:
- Around line 105-122: Restore sequential amendment ordering in the ADR
correction note by changing the visible item labels from 4 and 3 to the
appropriate 3 and 4 order, or reorder the entries accordingly. Keep each
amendment’s content unchanged and ensure the sequence is unambiguous.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 6f004819-231d-40a3-8a86-36c3b84b4f12
📒 Files selected for processing (6)
apps/cli/src/chat/repl-info.test.tsapps/cli/src/chat/repl-info.tsapps/cli/src/commands/chat.test.tsapps/cli/src/home/drive-home.test.tsapps/cli/src/render/tui/home-app.test.tsxdocs/decisions/0059-cli-mid-session-model-reseat.md
🚧 Files skipped from review as they are similar to previous changes (5)
- apps/cli/src/home/drive-home.test.ts
- apps/cli/src/chat/repl-info.test.ts
- apps/cli/src/chat/repl-info.ts
- apps/cli/src/commands/chat.test.ts
- apps/cli/src/render/tui/home-app.test.tsx
… toggle (2.6.C spin-off) Records the outcome of the "/models reseat forgets tool calls" investigation. The premise was wrong — the engine drops tool_use/tool_result at EVERY turn boundary, switch or not (ADR-0062 §6) — so this is an engine gap, not a reseat one, and it already had a home under the AgentSession (1.V) follow-ups. What the entry adds: gate the eventual carry behind a default-off `[chat]` toggle, and the observation that the toggle has exactly ONE axis rather than several — reasoning/signature can never be carried regardless (ADR-0030 is a cryptographic replay boundary, not a preference), and @-mention file content already carries correctly (8ba7737). And the sequencing that matters: a default-off toggle narrows WHO hits the three prerequisite bugs (no pre-egress window check; context overflow is a fatal, non-retryable bad_request; the budget gate prices only output tokens), not WHETHER they are real — and the opt-in population is precisely the one that runs long sessions. Shipping the toggle first would trade "the model forgot" for "the turn died with no recovery", for the users who opted in. Refs: ADR-0062, ADR-0059, ADR-0030, ADR-0028 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ocally, ENOENT on CI
`join(process.cwd(), 'drizzle')` is `packages/db/drizzle` when the package's own vitest runs, and the REPO ROOT under
the root `pnpm coverage` that CI actually invokes. So the two new migration tests passed every local run and failed CI
with an ENOENT on a path that never existed. Resolved from the module instead — `fileURLToPath(new URL('../drizzle',
import.meta.url))`, exactly as `client.ts` resolves `MIGRATIONS_DIR`. Verified by running the suite from the repo root,
which is the invocation that broke.
Docs: ADR-0059's 2.6.C note listed its items 1, 2, 4, 3 — the correction was inserted ahead of an existing entry
instead of appended after it. Reordered so the sequence reads, with no content changed. Its ADR-0062 link also pointed
at a filename that does not exist (`0062-cli-context-management-compaction.md`); the file is
`0062-context-compaction-and-cli-history-commands.md`, as the same ADR already links it correctly twice elsewhere.
NOT applied — Sonar's "'never' is overridden by other types in this union" on `type Executor = Db | DbTx`. Both probes
say otherwise: `[DbTx] extends [never]` is false (DbTx is a real type), and `Db extends DbTx` is also false (the two
are distinct, so the union is load-bearing — a transaction handle is not the connection). Sonar's resolution of
drizzle's overloaded `transaction` signature differs from tsc's; the code is correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/roadmap/deferred-tasks.md`:
- Around line 494-496: Update the roadmap entry around the model-switch
tool-context note to comply with the English documentation guideline: either
replace the linked analysis with an English translation, or remove the analysis
link until an English version is available, while preserving the existing
deferred-task context.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 5342be6e-5637-4553-b8ed-4881b30f73aa
📒 Files selected for processing (3)
docs/decisions/0059-cli-mid-session-model-reseat.mddocs/roadmap/deferred-tasks.mdpackages/db/src/session-store.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/db/src/session-store.test.ts
| > Raised while investigating a "`/models` reseat forgets tool calls" report (2.6.C / PR #75). Full analysis: | ||
| > [docs/analysis/private/model-switch-tool-context-carry-2026-07-12.md](../analysis/private/model-switch-tool-context-carry-2026-07-12.md) | ||
| > (Turkish). Recorded here — deliberately **not actioned** — pending a maintainer call on sequencing and risk. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Keep linked documentation English or omit the link.
This English roadmap entry links to an analysis explicitly marked “Turkish,” which violates the Markdown documentation language guideline. Translate the analysis before linking it, or remove the link until an English version is available.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/roadmap/deferred-tasks.md` around lines 494 - 496, Update the roadmap
entry around the model-switch tool-context note to comply with the English
documentation guideline: either replace the linked analysis with an English
translation, or remove the analysis link until an English version is available,
while preserving the existing deferred-task context.
Source: Coding guidelines
…h analysis file `deferred-tasks.md` is tracked; `docs/analysis/private/` is gitignored. The link therefore resolved to nothing for anyone who cloned the repo — a dead pointer in the one document whose job is to be the durable record — and it pointed at a Turkish file, against the English-only rule (CLAUDE.md rule 10). Nothing is lost by removing it: the entry already restates every finding inline in English, each with its own `file:line` (the three prerequisite bugs, the touchpoints, the sequencing). It stands alone, which is what a deferred entry has to do. Swept the rest of the repo: no other tracked markdown links into a gitignored path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
…db coupling it exposed Marks 2.6.C done in current.md, the phase plan, and CLAUDE.md (which still called Phase 2.6 "next up" while two of its workstreams were already on `main`). Files one finding that had nowhere to live. `regression.e2e.test.ts` drives the real CLI shell without a `--db`, so it resolves the DEFAULT path and opens — and MIGRATES — the developer's real `~/.relavium/history.db`. Verified rather than assumed: running that file with HOME pointed at an empty directory creates `$HOME/.relavium/history.db` with all 11 migrations applied. The sibling test at :315 already does it correctly (mkdtemp + explicit dbPath), so the isolation exists; this path just does not use it. It is worth fixing because it already bit, in this very workstream: a migration re-cut during development changes its journal timestamp, drizzle replays such a migration, and `CREATE TABLE session_costs` ran against the table it had itself created. CI could not see it — a fresh runner has no history.db, so nothing had been applied and nothing could conflict. The failure surfaced only against the maintainer's 3.3 MB database of real sessions. A suite that writes to real user data both damages it and blinds CI to the damage. Also records the cross-turn tool-call memory spin-off in current.md's open obligations, with the three already-live bugs that gate it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>



Closes 2.6.C. Two maintainer manual-test findings, and a residual that turned out to need its own ADR.
F1 — a
/modelsreseat blanked the screenA mid-session model switch builds a brand-new view store. On the full-screen renderer — the TTY default since 2.6.F, and whose alt buffer has no native scrollback behind it — that store is the scrollback. It was seeded
transcript: [], so the entire conversation vanished the moment a user switched models.The durable transcript and the new model's context were never at risk. The loss was the view — which is exactly why it slipped through: the existing reseat test is literally named "carrying the transcript" and asserted
turnCount,model,sessionIdand DB rows. Neverstate.transcript.The reseat now carries the rendered conversation, and the notice lands beneath it as an inline marker:
The carry is gated to full-screen, and that gate is load-bearing in both directions.
driveInkre-mounts ink per session, which resets<Static>'s index — so a seeded inline store would re-print the whole conversation on top of the copy the terminal's scrollback already holds. Inline needs no carry; its lines survive the swap./clearcarries nothing on either renderer — itsrebuildpath has no carry parameter at all, so its freshness is structural.TranscriptBoundbecame a closed literal union with no defaults, because the dangerous axis was never the seed — it was the bound. A full-screen caller that forgot the argument used to fall back to inline silently: F1 again, with a green build. Now it is a compile error./cost— where the money wentADR-0059 planned to derive the breakdown from the per-message
model_id. That cannot work. One row holds one model, but a turn whose tool loop fails over bills two — pinned by an existing core test. So this needed its own decision: ADR-0070.A durable
session_coststable, with the invarianttrue by construction — both sides fed by the same
cost:updatedegress, same arithmetic, same transaction, single owner./costis a money surface: a breakdown whose rows do not sum to the total shown is worse than no breakdown.It closed a live bug on the way:
total_cost_microcentswas being SET blindly by five writers, so twochat-resumeprocesses on one session could permanently corrupt the total. It now has exactly one.Two honesties the panel carries: a
$0row with real tokens is a model we could not price, not a free one (2.6.Q's F5 is precisely that unpriced models are where the cost cap silently stops applying); and a session older than 2.6.C says its breakdown is unavailable rather than pretending it used one model.Stated plainly, not buried: the guarantee is against Relavium's own event stream, not the provider's invoice. An egress that streamed content but ended without a usage chunk, and a mid-stream failure, are recorded as 0 on both sides — so reported spend is a systematic under-estimate. That is a usage-capture gap in the seam, filed against 2.6.Q.
What the reviews caught in my own work
Four adversarial rounds ran over this. Each found something real, and the record is worth having:
NODE_ENV;apps/clihas no error boundary) — and ink swallowed it anyway (no-op error callbacks), so it could never fail a test. Probed and confirmed. It moved to the composition roots, where a throw actually escapes.wrapTranscript, and a quadratic regression fails it./costDB read — the step's central change — had zero test coverage at any layer. Reverting it to memory left the suite green.All folded, each with a break-verified regression.
ADR notes (append-only)
DEFAULT_ALT_SCREEN=trueplus Decision (c)'s move of the transcript into the in-memory viewport is what turned the ADR-0059 reseat into a visible loss.Verification
pnpm turbo run lint typecheck test build— 24/24 green. 2132 CLI · 985 core · 207 db · 543 llm · 459 shared.Every fix is break-verified: the bug is re-introduced, the test is shown to fail, the code is restored. F1's break reproduces the user's screen exactly —
expected [ 'notice' ] to deeply equal [ 'user', 'assistant', 'notice' ].Migration 0009 creates
session_costs, dropssession_messages' never-written token/cost columns, and backfills a(pre-2.6.C)sentinel row per legacy session so the invariant holds for every row — not just post-migration ones.🤖 Generated with Claude Code
Summary by CodeRabbit
/costnow shows durable per-session totals plus a per-model breakdown with token usage, call counts, and “price unknown” indicators./modelsreseat includes an explicit old → new notice and (in full-screen) preserves the rendered conversation./costafter resume reflects previously persisted spend breakdown (and no longer shows transient zeros)./costoutput, reseat behavior, and accessibility wording.