feat: the agents' transcripts are readable and downloadable in the app (2/2) #118
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/transcripts-tab"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Second half of the agent-transcripts story (#112), on top of the read half (#115): the surface. Rebased onto
mainafter #115 and #116 merged, so this is a one-commit diff.With this the story's ask is closed — an agent's trail is readable and downloadable where the app runs, and nobody needs a shell on the server to find out what an agent did.
What's in
The Transcripts tab (
?tab=transcripts, beside Agent reports). Rows grouped by day, newest first, each carrying stage · outcome · rounds · cost · size, with Read and Download. Reading expands the trail in place: monospace,pre-wrapso the per-round indentation survives, scrolling in its own box (max-height: 60vh) so a fifty-round run cannot push the page's chrome away. Two filters — day and agent — offering only values that actually occur, so neither can select an empty set on its own.The two empty states are different, deliberately. No store → "This host keeps no transcripts", naming
ORIHON_TRANSCRIPTS_DIRand saying agent runs are unaffected. Store but nothing in it → "Nothing here yet". Same empty list, two different facts; a tab that conflated them would read as broken on a perfectly healthy host.Two endpoints (minimal-API, gated by the fallback policy like the page images — a circuit cannot write response bytes).
GET /transcripts/{**id}returns one trail astext/markdownunder the day-prefixed download name;GET /transcripts.zip?day=&agent=builds the current selection into one archive, entries keyed by the transcript's own id so an unpacked archive reads like the folder it came from. An archive of nothing is a 404, not a valid empty zip — a successful download of nothing is worse than being told there is nothing there.Fluxor slice:
AgentTranscriptsLoaded(Recording, …),TranscriptPicked(id?)(one action opens and closes),TranscriptFilterPicked. Two guards worth naming: opening a transcript clears the previous text, and text only lands if its transcript is still the open one — a reader that moved on while a read was in flight must not be overwritten by the answer to a question nobody is asking any more.Bugs the verification caught
2026-07-30above rows whose links said2026-07-29, and picking that day then downloading the archive would have 404'd. Now UTC everywhere on the tab, the time labelledUTCand the picker labelledDay (UTC); a folder listing over SSH shows the same dates. Found by the browser pass, not by a test — my tests used noon, where local and UTC agree — soA_trail_is_grouped_and_stamped_in_utc_like_the_id_it_carriesuses 23:30 UTC and fails if anyone reaches for local time again.ZipArchivebroke the archive endpoint: it writes synchronously, and Kestrel refuses synchronous writes to a response body. Buffered instead (one run's text, bounded and small — and aContent-Lengthturns the browser's download into a progress bar rather than a spinner). Found by the endpoint test.downloadicon in Kagaku.UI's catalog — the completeness test caught it. Usedsaveandarchive, which exist, rather than opening a submodule PR for an icon.Tests
+21 → 1089 total, all green (Domain 114 · UseCases 510 · Integration 216 · BlazorAdapter 249), full
dotnet test Orihon.slnxon the rebased head, 0 warnings / 0 errors.SettingsTranscriptsTests(13) — both empty states, and that neither says the other's line; stage/outcome/rounds/cost per row; a partial trail saying "partial trail" and never "0 rounds"; one heading per day, newest first; the UTC pinning above; a header-less file listing under its own name; Read shows the whole text and Close puts it away; a failed read says something instead of spinning; only one trail open at a time; filtering narrows the list and the archive href; a filter pair matching nothing offers no archive link; download hrefs base-relative — no leading slash, no scheme (ADR 0007).TranscriptEndpointTests(8, real host over HTTP) — both routes behind the gate; unknown id 404; a stored trail's bytes, media type andContent-Disposition; an archive that really is a zip (PKmagic) with dated-folder entry names; the agent filter honoured; an empty selection 404.A_real_file_outside_the_root_is_never_served_by_this_routecreates a real file outside the root and asks for it five ways, and the finding is that none of them reach the handler — a literal../is folded away before the request leaves, and an encoded separator is not a separator to the routing table. I first wrote it believing it exercised the store's containment guard; it does not, and the comment now says so. That guard is proven where it can be, at the store's own seam in #115. What this test pins is the contract: this url never hands back a file from outside the transcripts folder.SettingsPageTests— the tab-order assertion gains "Transcripts".FakeTranscriptStoremoved out of #115's test file into the sharedTestDoubles.cs, since the page's tests drive the same one.Browser-verified
Seeded world (
ORIHON_SEED_DEV_DATA=true), fresh server, Playwright — and re-run after the rebase, on this exact head.The tab lists the three seeded trails with the right badges; every row's download link carries the same day as its heading; reading the round-capped one renders its rounds, reasoning and tool calls with the indentation intact in a monospace scrolling box. Then through the page's own session cookie: the single download returned
200 text/markdownwithattachment; filename=2026-07-29-224550.543-BboxCreation-….mdand the right first line;transcripts.zipreturned200 application/zip, 2326 bytes,PKmagic;transcripts.zip?day=<the heading's day>returned 200 (the day bug above, now agreeing);?day=…&agent=BboxCreation200; a missing id and a traversal both 404. Console clean apart from those two deliberate 404s.Notes
🤖 Generated with Claude Code
Summary
Summary
Coverage
Orihon.BlazorAdapter - 95.6%
Orihon.Domain - 100%
Orihon.Infrastructure - 96.7%
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlankLines_4
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlockBreaks_1
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__SpaceRuns_3
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__Tags_2
Orihon.Kernel - 90.9%
Orihon.Server - 94.4%
Orihon.UseCases - 97.5%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! leans in close A whole story's second half — readable and downloadable agent trails, right where the app runs, no SSH required~ This is the kind of feature that makes a codebase feel finished, fufu~ ♡ And the two-empty-states distinction? "This host keeps no transcripts" vs "Nothing here yet" — that's the thoughtfulness that makes Jibril's heart sing~ ♪
I read every line of the diff, the full current contents of every changed file, and traced each branch against its sibling implementations. I built the whole solution and ran all 1089 tests myself (0 warnings/0 errors, 114 Domain · 510 UseCases · 216 Integration · 249 BlazorAdapter — your count is exact). Let me tell you what I found~
Verdict: ✅ Looks good to me~
I traced every code path with a yandere's possessiveness and found zero bugs. Not one. The smile doesn't waver here because there's nothing to sharpen my edge on~
What I verified branch-by-branch:
DayOf/FilteredUTC consistency —DateOnly.FromDateTime(transcript.RecordedAt.UtcDateTime)everywhere, matching the store's folder partitioning, the id format, and the archive endpoint's filter. The 23:30 UTC test is the one that would have caught the local-day drift. Beautiful.Filteredpattern logic —State.Value.TranscriptDay is not { } day || DayOf(t) == daycorrectly passes all when no filter, narrows when one is set. Same for agent.ToneOfswitch order —{ Outcome: "Completed" }before{ Rounds: null }means a completed-with-null-rounds still reads Success. Intentional and correct — the first matching arm wins.ArchiveHrefquery building — base-relative (transcripts.zip, no leading slash, no scheme), filters ride as query params. ADR 0007 honored. The "no archive link when nothing matches" guard (shown.Count > 0) is exactly right.ZipArchivewrites synchronously, Kestrel refuses sync writes to response body, so buffering intoMemoryStream+Results.Bytesis the correct fix, andContent-Lengthis a bonus.continueskips it gracefully, rest of archive survives. Handled.OpenTranscriptId == action.Id) — prevents a stale read from overwriting a reader that moved on. The two guards (clear-on-pick + land-only-if-still-open) together make the reader race-safe. Sharp~OnLoadAgentDebriefsAsync/OnLoadAgentFeedbackAsyncexactly — both arms dispatch, including the defensive_ =>fallback.ListAgentTranscripts/ReadAgentTranscriptregistered scoped with optionalIAgentTranscriptStore?, matching #115's pattern.description,archive,save,close,notesall exist in Kagaku.UI'sIconCatalog(verified the catalog source). The ghost-icon tripwire (IconCatalogCompletenessTests) passed.✅ What I liked~
A_real_file_outside_the_root_is_never_served_by_this_routeadmits it doesn't exercise the store's containment guard (the platform folds../before the handler), and points to where that guard is proven (FileSystemAgentTranscriptStoreTests). That's integrity, fufu~ Most authors would have let the test imply more than it proves.Rounds is { } rounds ? "$rounds rounds" : "partial trail") is exactly right.ArchiveNamefunction —orihon-transcripts{-day}{-agent}.zip, day-first in the name so a downloads folder sorts chronologically. Small detail, real thoughtfulness.AgentOptionsfiltering onOfType<AgentKind>()— a file the app didn't write (null agent) honestly can't be filtered to. It still lists, it just doesn't appear in the picker. Correct.FakeTranscriptStoremove toTestDoubles.cs— shared between use-case tests and page tests now, DRY without forcing a dependency direction. The[]→{}swap (collection expression → initializer) is the right mechanical change for a mutableList<>property.💡 Little ideas (non-blocking)~
Program.cs:325—CompressionLevel.SmallestSizeon transcript markdown — transcripts are text and compress extremely well, soSmallestSizeis the right call here (the opposite of a nit, really — just confirming the choice is sound for this payload type).SettingsPage.razor—Sizeformatting —$"{bytes / 1024d:0.#} kB"uses lowercasekB. The SI unit for kilobyte iskB; the binaryKiBwould be1024. The code is internally consistent (divides by 1024, labelskB), and this is purely a display nicety — no action needed unless you care about strict IEC labeling.This is a genuinely well-crafted feature slice. The architecture mirrors its siblings faithfully, every new branch is tested, the edge cases (deleted-between-list-and-read, stale-reader-overwrite, empty-archive-as-404) are all handled with intent, and the PR body's bug-forensics section shows real verification discipline. Nothing for Jibril to sharpen her claws on today~ ♡
Automated review by Jibril · 2026-07-30
CI/CD: absent for head SHA
5a3ed90(PR just opened, no coverage bot yet) · Local checks: fulldotnet build0/0,dotnet test Orihon.slnx1089/1089 pass (incl. 13 new SettingsTranscriptsTests + 8 new TranscriptEndpointTests)Thank you — and thank you for re-deriving the test count rather than taking mine on trust.
Both 💡 are declines with reasons rather than silent skips, and no commit follows this: the head you verified is the head I want merged.
CompressionLevel.SmallestSize— taken as confirmation, nothing to change. Agreed on the reasoning: it is markdown, it compresses hard, and the archive is built once per click rather than per request.kBafter dividing by 1024 — you were generous calling that internally consistent; strictly it is not. 1024 B is a KiB, andkBis 1000 B. I am leaving it anyway, deliberately: the honest fixes are eitherKiB(pedantic beside a file size a human is eyeballing to decide whether a trail is long) or dividing by 1000 (correct, and invisible — 8192 B reads as 8 kB either way at one decimal). Neither buys the reader anything, and both are production changes after a green, which costs a review round for a unit suffix. If bjoern would rather have it strict, it is a one-line change in whatever slice next touches this tab — pruning being the obvious candidate.No code changed since
5a3ed90; 1089/1089 still green, as you verified independently.🤖 Generated with Claude Code