Skip to content

Production readiness: streaming console, correctness fixes, tests, docs - #1

Merged
corecompiled merged 9 commits into
mainfrom
feat/production-readiness
Aug 2, 2026
Merged

corecompiled merged 9 commits into
mainfrom
feat/production-readiness

Conversation

@corecompiled

Copy link
Copy Markdown
Owner

Takes OpenKey from a working prototype to something shippable. Seven commits, staged so each is reviewable on its own and leaves a runnable exe.

Why

A scan of the code and docs found three classes of problem: defects that made the app feel broken in normal use, a console that read as a developer tool, and a doc set with 16 internal contradictions — following docs/02 verbatim produced a project that did not compile.

The bugs that mattered

Three were invisible from a desk and only surfaced by running and testing the code:

  • Replies were never saved. Session persistence ran after yield return of the final chunk. The console stops reading there, which disposes the iterator, so the code never executed. No conversation had ever been written to disk in any build. Draining the stream to completion hides this entirely — the regression test deliberately breaks early the way the host does.
  • Resume had never worked. ChatMessage had two constructors, so System.Text.Json threw NotSupportedException — a type the session store does not catch.
  • Slow models could never succeed. HttpClient.Timeout covers reading the body even under ResponseHeadersRead, so a healthy long reply was aborted, misread as a network fault, and retried onto a model that died identically.

Plus: one Ctrl+C permanently disabled the session; captive-portal Wi-Fi crashed first run with no visible error; a mid-reply model switch showed the answer twice; /reset could strand you in an inescapable loop; an empty model list bricked the app for 24 hours.

The console

Replies now stream. Each completed markdown block is repainted styled while the arriving tail stays raw — styled means settled, raw means still coming.

LiveDisplay was the obvious tool and is the wrong one: reading Spectre 0.57.2's source, it clamps to the viewport, discards overflow lines rather than scrolling them, and on shrink issues EraseInDisplay(2) + ClearScrollback() — which would wipe the transcript. TranscriptWriter drives the cursor directly and refuses to rewind in the three cases where erasing would be wrong rather than merely ugly.

Also: error cards that say what to do next, ASCII glyph fallback (the spinner was Braille and ran on every turn), sentence-case voice with no DPAPI/OAuth/429/ChatErrorKind on screen, and an exit hold so a double-clicked exe stops vanishing before you can read anything.

Contract changes

Approved beforehand: InvalidRequest added to ChatErrorKind; NetworkDown no longer rotates or counts against a model; ChatChunk gains IsAttemptRestart; writes guarded per docs/05, which already specified behaviour the code never implemented.

Also

  • Tier 2/3 backlog cleared: /new, /retry, /history, /copy, /export, /theme, plus config.json persistence and a real tokenizer.
  • A security fix: Microsoft.ML.Tokenizers pulls in Microsoft.Bcl.Memory 9.0.4, which carries a known high-severity advisory (GHSA-73j8-2gch-69rq). NuGet audit failed the build; pinned forward rather than shipped.
  • Tests 11 → 84. docs/architecture/ added — 08-decisions.md records what was rejected and why.
  • Publish flags moved into the csproj: they existed only as README prose, so any publish not pasting that exact line silently produced a different artifact than the one smoke-tested.

Verified

Build clean with trim/AOT analyzers on, 84/84 tests, live chat against a real model, resume restoring a real session, redirected output staying plain, bare dotnet publish producing the exe, and the streaming rewind confirmed non-destructive in a real terminal.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ

corecompiled and others added 9 commits August 3, 2026 03:34
Every package was behind, and the console rebuild is about to be written
against Spectre. Bumping first keeps a library surface change from being
mistaken for a UI regression.

  Spectre.Console                 0.49.1  -> 0.57.2
  Microsoft.Extensions.DI         9.0.0   -> 10.0.10
  System.Security.Crypto.Protect  9.0.0   -> 10.0.10
  Markdig                         1.2.0   -> 1.3.2
  Microsoft.NET.Test.Sdk          17.11.1 -> 18.8.1
  xunit / runner.visualstudio     2.9.2   -> 2.9.3 / 3.1.5

The Spectre 0.55 breaking changes (Style became a struct, Capabilities
.IsTerminal removed, Table/Grid.Alignment removed) turned out not to touch
any existing call site — the upgrade compiled clean.

Move the publish flags out of README prose and into OpenKey.csproj. They
only existed as documentation, so any `dotnet publish` that did not paste
the exact command — including CI, once it exists — silently produced a
different artifact than the one that got smoke-tested. A bare
`dotnet publish -c Release -r win-x64` now yields the single-file exe.

Convert System.Text.Json to source-generated contexts. Anonymous request
bodies cannot be source-generated, so the OpenRouter chat request and the
OAuth key exchange become named records; this also drops the
`"temperature": null` that was serialized on every request. Enabling
IsAotCompatible across all three projects is what surfaced these, and the
tree is now warning-clean with the trim/AOT analyzers on — which retires
the "Spectre reflection blocks AOT" claim in docs/06 as stale and shows
Markdig is clean at our call sites too.

Also: pin the SDK via global.json so CI cannot resolve a different one,
and add win-arm64 alongside win-x64.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
Fourteen defects found by an interaction review, none of them marked in the
code — a grep for TODO/FIXME/NotImplemented returns nothing, so every gap
here was structural rather than known-and-deferred.

The two that mattered most were invisible from a desk:

HttpClient.Timeout was 60s, and that budget covers reading the response
body even under ResponseHeadersRead. A slow free model generating a long
reply would blow through it, get aborted, be misclassified as NetworkDown,
rotate — and die the same way on the next model. On a slow model the app
could never succeed. Timeout is now infinite and the provider applies
per-read deadlines instead, which is what actually distinguishes a slow
model from a dead connection.

Captive-portal Wi-Fi (hotels, airports) answers any request with a login
page and HTTP 200. JsonDocument.ParseAsync threw, nothing caught it, and
with no top-level handler the window closed on the same frame as the stack
trace. For a product distributed on a USB stick that is the likeliest
first-run experience in the wild.

Also fixed:

  - One Ctrl+C poisoned the session. A single process-lifetime CTS was
    cancelled forever, so every later turn was born cancelled. Now one CTS
    per turn; Ctrl+C mid-reply cancels that reply, at the prompt it exits.
  - A mid-reply rotation rendered the answer twice concatenated while
    session.json stored it once, leaving screen and history permanently
    disagreeing. ChatChunk now carries an attempt-restart signal.
  - /reset that ended without a key dropped the user into a keyless REPL:
    every message failed auth, the error said "run /reset", and /reset
    landed back in the same place. Inescapable without killing the window.
  - An empty free-model list was cached with a fresh 24h TTL, then crashed
    on a bare InvalidOperationException every launch — unrecoverable
    without deleting %APPDATA% by hand.
  - Failed and cancelled turns left their user message in history to be
    persisted on the next success.
  - Streams ending with [DONE] and no prior finish_reason had their
    complete reply discarded as malformed and retried elsewhere.
  - Free-model detection string-matched "0"/"0.0"/"0.00", so any other
    zero formatting silently marked a free model as paid.
  - Multi-line paste submitted the first line and let the next prompt eat
    the rest, so a pasted transcript containing /reset could open the wipe
    confirm with the following line answering it.
  - Every disk write was unguarded, so a full disk or read-only roaming
    profile crashed after a reply was generated but before it was shown.
    Now best-effort, except the key: failing to persist that silently
    means re-authenticating every launch, so it still reports.

Contract changes, approved beforehand: ChatErrorKind gains InvalidRequest
(400/404/422 are unretryable — the request is at fault, not the model);
NetworkDown no longer rotates or counts against a model; ChatChunk gains
IsAttemptRestart; writes are guarded per docs/05, which already specified
this behaviour the code never implemented.

The RollingWindow test asserting NetworkDown was retryable encoded the old
rule and now encodes the new one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
The old console showed nothing at all until a reply was complete, printed
raw ChatErrorKind enum names at users, and wrote its goodbye to a window
that closes instantly on double-click — so nobody in the target audience
had ever seen it. Colour and glyph literals were scattered across three
files with three different cases and four border styles, sometimes inside
a single method.

Replies now stream. Raw text appears as it arrives and each completed
markdown block is erased and repainted styled, so a fence renders as a
panel and prose renders with emphasis, while the still-arriving tail stays
raw — styled means settled, raw means still coming.

LiveDisplay was the obvious tool for this and is the wrong one. Reading
Spectre 0.57.2: it clamps the region to the viewport and discards overflow
lines rather than scrolling them, and on shrink it issues EraseInDisplay(2)
followed by ClearScrollback() — which would wipe the chat transcript. So
TranscriptWriter drives the cursor directly, gated on caps.Ansi, using
EraseInDisplay(0) and relative CursorUp. It refuses to rewind in the three
cases where erasing would be wrong rather than merely ugly: past the
viewport (those rows are in scrollback and unreachable), after a resize
(the row count is stale and would eat unrelated transcript), and when
output is redirected. Row counting is width-aware because CJK, emoji and
combining marks all break a naive character count, and wrapping is figured
at width-1 since conhost and Windows Terminal disagree about a glyph
landing exactly on the last column.

Chat input moves from Spectre's TextPrompt to Console.ReadLine. Spectre's
reader handles only Enter, Tab, Backspace and printable characters — it
silently drops arrow keys — whereas the Windows console gives line editing
and history for free. It also cannot throw when output is redirected, which
Spectre prompts now do, straight into a bare catch that exited the REPL.

Glyphs are tiered. Spinner.Known.Dots is Braille U+28xx, absent from
Consolas, and it ran on every single turn: the most likely visible breakage
in the app. The caret U+276F is in neither CP437 nor Consolas. Both, plus
rounded borders and the bullet, now degrade to ASCII outside Windows
Terminal.

Errors became cards that say what happened and what to do next; a card with
no next step is a bug. Rotation is one grey line, not a yellow warning per
attempt — rotation working correctly is not a warning. Inline code lost its
"white on grey23" background, which downsampled to white-on-black and was
invisible on light schemes and identical to body text on dark ones. The
markdown renderer now puts one blank line between blocks; it previously
emitted none, so headings and lists butted together. /models shows the
model name and context size instead of a raw id, finally consuming the
DisplayName that was fetched and never displayed. Voice is sentence case
throughout, and no screen says DPAPI, OAuth, 429 or ChatErrorKind.

Three defects found by running it rather than reading it: the version
banner carried the SDK's "+<sha>" suffix; the banner printed twice whenever
the screen could not be cleared; and Cursor.Show() in the turn's finally
threw "The handle is invalid" under redirection, killing the app right
after a successful reply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
Grows the suite from 11 tests to 65. The gap was structural: there was no
IChatProvider fake anywhere in the repo, so ChatEngine — the entire retry
and rotation state machine — had never been executed by a test at all.
FakeChatProvider makes attempts scriptable, which is what the engine tests
needed to exist.

Writing them found two real bugs that no amount of reading had surfaced.

The first: replies were never persisted. The success bookkeeping in
SendAsync — MarkSuccess, appending the assistant turn, saving the session —
sat *after* `yield return` of the final chunk. A consumer that stops as
soon as it sees IsFinal, which is the natural way to consume the stream and
exactly what the console host does, disposes the iterator at that yield, so
none of it ever ran. session.json was never written and no model success
was ever recorded. Committing now happens before the final chunk is
yielded, so it cannot depend on how the consumer behaves. Draining to
completion hides this entirely, so the regression test deliberately breaks
early the way the host does.

The second: ChatMessage had two constructors, and System.Text.Json refuses
to choose between them, so deserializing a session threw
NotSupportedException — a type JsonSessionStore does not catch. Restoring a
saved conversation had therefore never worked in any build. Marking the
intended constructor fixes it. Resume now genuinely round-trips, verified
against a real session file.

TranscriptWriter also gained a design fix. Its rewind decision came from
global ConsoleLayout state rather than the console it was handed, and the
legacy-conhost fallback branch called Cursor.Move, which throws without a
real console handle. That branch was unreachable in production anyway —
the Rich guard already excludes every case that reached it — so it is gone,
and the writer now judges from its own console's capabilities.

Coverage: engine success, rotation, restart signalling, the NetworkDown and
InvalidRequest no-rotate rules, user-turn removal on failure and on cancel,
and empty-catalog handling; SSE framing including [DONE] without a
finish_reason and in-stream error objects; the captive-portal HTML-with-200
case; HTTP status mapping across nine codes; price-parsing for free-model
detection including the "0.000000" form the old string match got wrong;
session round-trip, corruption quarantine, and best-effort writes; block
splitting across delta boundaries with fences spanning chunks; and
width measurement for CJK, emoji, surrogate pairs and combining marks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
The doc set had 16 internal contradictions and no entry point for either a
user or a contributor. Following docs/02 verbatim produced a project that
did not compile, because Step 2's package list omitted Markdig and included
Microsoft.Extensions.Hosting, which the code correctly never used.

New:

  docs/architecture/    layers, request lifecycle, rotation, storage,
                        provider, console host, error taxonomy, decisions
  docs/08-user-guide    every feature and every failure, for a non-technical
                        reader; no acronyms
  docs/09-testing       test layout, the two helpers, and the two
                        conventions that catch real bugs
  BACKLOG.md            execution state and order
  LICENSE               MIT — the repo was all-rights-reserved by default,
                        which blocked the F-Droid plan in the roadmap
  CHANGELOG, CONTRIBUTING, SECURITY
  .github/workflows     CI on push, both architectures on a v* tag

08-decisions.md is the one worth reading. It records what was rejected and
why, with evidence — LiveDisplay destroying scrollback, Polly corrupting
rotation's failure accounting, IChatClient breaking the cross-UI contract —
so that ideas which look obviously right do not get re-proposed annually.

Contradictions resolved, code treated as truth:

  - docs/02 Step 2 omitted Markdig and prescribed Hosting
  - docs/02 and docs/05 spelled out divergent /reset sequences; 05 is
    normative and 02 now links to it
  - docs/02's OAuth sequence used an ephemeral port, contradicting docs/03,
    which explains why the port must be fixed
  - docs/04's backoff prose was off by a factor of two and never reconciled
    its 1 hour cooldown against the 5 minute cap
  - docs/04 prescribed per-attempt console output from Core, which both
    violates the layering and corrupts the streamed reply
  - docs/00 promised silent rotation; it now shows one quiet line
  - docs/00 and docs/02 described the buffered spinner the rebuild replaced
  - docs/01 said "three surfaces" and listed four
  - docs/03 had two headings named "Error mapping"
  - docs/06 rejected AOT for a reason that expired when Spectre 0.55 shipped
    trim annotations; the section now states measured status
  - docs/06's smoke checklist tested a UI that no longer exists
  - /help, /about and /models sat in the Phase 1.1 section while also being
    Phase 1 acceptance items, violating the ladder's own lowest-tier rule
  - the tier ladder was duplicated in CLAUDE.md, breaking the no-duplication
    rule it defines
  - <user> placeholders, a placeholder README link, and a 1.0.0-vs-0.1.0
    version claim
  - roadmap Phase 5 marked Claude models IsFree: true, which would surface
    paid models in the free picker

Both acceptance checklists now reflect reality rather than sitting entirely
unchecked while the README claimed the app shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
Every remaining item below Tier 4. The commands are the visible part; the
prerequisite was that OpenKey had nowhere to remember anything.

  /new       start fresh, keeping the key
  /retry     resend the last message
  /history   the conversation so far
  /copy      last reply to the clipboard, via clip.exe
  /export    markdown, defaulting to a timestamped file on the Desktop
  /theme     default | dark | light | mono, remembered

/new was the most conspicuous missing verb in the app: clearing history
previously required /reset, which also deleted the key and forced a new
sign-in. /retry routes back through the host rather than sending from the
router, so a resent message takes exactly the same path as a typed one.

IAppPaths.ConfigFile had been declared and never read or written since the
first commit, which is why a pinned model could only last until the app
closed. IConfigStore/JsonConfigStore fill it, matching the shape docs/05
already specified — preferredModels doubles as the pin, so no contract
change was needed. Users are told they may edit this file, so it is treated
as untrusted input: blank ids dropped, unknown themes reverted, silly token
limits reset, corrupt files quarantined like a corrupt session.

Token counting moves off chars/4 to a real tokenizer. It goes behind
ITokenCounter because OpenKey.Core deliberately has zero package
references — that absence is what keeps host concerns out of the shared
layer — so Core defines the interface and keeps the heuristic as its
fallback, while the host supplies a cl100k-backed one. The vocabulary is
embedded rather than downloaded: OpenKey has to work on first run behind a
captive portal and makes no network call except to OpenRouter.

Robustness:

  - Browser sign-in tries four known callback ports instead of only 3000.
    They stay fixed rather than random, because OpenRouter keys its app
    record on the callback URL and 409s if it varies.
  - A whole turn is now bounded, not just each attempt. Checked between
    attempts only — a reply that is actively arriving is working, however
    long it has taken, and cutting it off would waste it.
  - Link URLs are escaped instead of bracket-filtered. The old filter
    silently dropped the target of any URL containing a bracket.

Two backlog items were resolved differently from how they were written:

/stop was not added. A command cannot work while a reply streams — the app
is not reading a prompt — and Ctrl+C already cancels correctly. The real
gap was that nothing said so, so the fix is a one-off "(Ctrl+C to stop)"
hint. A key-watcher was considered and rejected: it would swallow
type-ahead, and people routinely start composing the next message while a
reply arrives.

Richer banner artwork was not added. The console design principle is that
calm beats decorative, and the banner is the first thing a non-technical
user sees. Art would contradict the design it is meant to serve.

The accessibility quick win is now enforced rather than asserted: mono is a
real palette, and a test proves no hue survives it, so anything that starts
carrying meaning in colour alone fails the build.

Also caught en route: Microsoft.ML.Tokenizers 2.0.0 pulls in
Microsoft.Bcl.Memory 9.0.4, which carries a known high-severity advisory
(GHSA-73j8-2gch-69rq). NuGet audit failed the build; pinned forward to
10.0.10 rather than shipped.

84 tests, up from 65.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
Version had stayed at 0.1.0 through streaming replies, six new commands,
preference persistence and a dozen correctness fixes. Bumped and the
changelog entry dated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
CI caught this on its first run; it passed on my machine.

Two defects, one visible and one that hid it.

The visible one: a single delta can carry both the opening and closing
fence marker, and the fence check asked "are we inside a fence" rather than
"does the pending text contain a fence at all". Both markers net to
not-inside, so an entire code block reached the screen as raw backticks
before the block flush replaced it. A model emitting a short fence in one
chunk would show it.

The one that hid it: TranscriptWriter decided whether it may stream raw
text from `console.Profile.Capabilities.Ansi && ConsoleLayout.Rich`. That
second term is global mutable state no caller controls, so the same code
streamed raw on one machine and not on another, and the tests could not pin
it down. caps.Ansi is already false whenever output is redirected, which is
the only thing the global contributed — so the writer now judges solely
from the console it was handed.

The regression test runs with ANSI both enabled and disabled, since a bug
that only appears on one capability profile is exactly what got through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
ResetDiscardsEverythingFromAnAbandonedAttempt counted occurrences of the
answer, which only means anything when the console forbids raw streaming.
With ANSI available the writer streams raw and then issues an erase — but a
StringWriter has no cursor, so nothing is removed and the count is three.
That is correct behaviour being measured by the wrong instrument, and it is
why the test passed locally and failed in CI.

It now asserts what is actually true in each case: an erase was issued when
raw streaming happened, exact occurrences when it did not.

Adds the first test that reaches the rewind path at all, since it only runs
on an ANSI console. It pins the sequence: never EraseInDisplay(2), never
ClearScrollback. Those are precisely what LiveDisplay does on shrink and
precisely why it was rejected, so a future change reaching for them fails
the build rather than silently eating the conversation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
@corecompiled
corecompiled merged commit 57c1d24 into main Aug 2, 2026
1 check passed
@corecompiled
corecompiled deleted the feat/production-readiness branch August 2, 2026 21:11
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.

1 participant