ENG-756 - [platform] A finished agent call's transcript is an immutable file but is re-downloaded, un-cached, in serial chunks, showing nothing until complete#86
Conversation
There was a problem hiding this comment.
Verdict: changes requested
The substance of both payoff items landed and matches the plan. The blocker is a typecheck failure in the new test file that this diff introduced — CI's npm --prefix frontend run build is red on it.
What holds up
- AC-1 (backend cache headers).
get_transcriptinjectsResponse, reads the call throughAgentCall.get(call_id)as the plan specified, and branches oncall.get_live_status() == AgentCallStatus.RUNNING—no-storewhile running,public, max-age=31536000, immutableotherwise. TheTranscriptChunkresponse model, camelCase aliasing, path, and query params are untouched, and/streamstill usesSSE_HEADERS. Route tests assert both exact header strings, and the running-call test keeps the full camelCase body assertion. - AC-2 (progressive non-live render).
setInitialnow fires inside the pagination loop under thecancelledguard, and the!isLivebranch rendersStreamTranscriptdirectly from the accumulated state withcomplete={initial.eof}— which is what makes later publications visible rather than swallowed byRunTranscriptLive's one-timeuseState(initialText). ThereceivedChunkflag preserves the old no-chunk fallback, so a failed first request doesn't create a new stuck state. The component doc comment was updated as the plan asked. - AC-3 (live handoff unchanged). Live mode still exits after one fetch, pins the SSE URL at that chunk's
nextOffset, appendstranscript.chunk, and flipsenabledto false onagent_call.finished. The new test asserts all four. - Scope is clean: no dependency, lockfile, migration, or config changes, and none of the ruled-out approaches (plain-text terminal endpoint, app-level cache, ETag revalidation, row virtualization) appear in the diff.
What needs a change
frontend/src/components/RunTranscript.test.tsx:75and:81— TS2722, CI-failing. Inline comment has the detail and options.backend/tests/test_api_runs.py— no coverage for the derived-abandoned branch that AC-1 names by hand. Inline comment has the detail.
Verification I ran
uv run ruff check backend— passed locally.- Node is not installed in this sandbox, so
npm run lint,npm test, andnpm run buildcould not run here; Postgres is likewise unavailable, souv run pytest backend/errored on connection setup locally. I used the PR's own CI as evidence instead: the backend job is green (947 passed, 1 skipped; proof-extension suite 14 passed), and on the frontend jobeslintandvitest runare green (53 passed, including both newRunTranscripttests) whiletsc -b && vite buildfails with the two TS2722 errors above.
Note that vitest and tsc disagree here — the tests pass at runtime and only the typecheck rejects them, so the fix is narrowing, not behaviour.
Follow-up recommendations (not blocking)
get_transcriptraises a second 404 whenAgentCall.get(call_id)returnsNone, butreads.read_transcript_chunkalready returnsNonefor a missing call, so that branch is unreachable in practice. Harmless, and it keeps the route honest ifreadsever changes — worth a comment or removal at some point, not now.- The immutable header is computed from the status at read time. A chunk fetched in the instant between the call going terminal and the last bytes landing on disk would be cached for a year. The window is tiny and the ticket accepts it; flagging it so it's on record.
Push back on any of this — if you read AC-1's verification clause as intentionally narrower than its description, say so and I'll take the abandoned-branch test as optional.
|
|
||
| act(() => { | ||
| const handlers = useSSEMock.mock.calls.at(-1)?.[1].handlers | ||
| handlers?.['transcript.chunk']({ text: 'streamed row\n' }) |
There was a problem hiding this comment.
tsc -b fails here in CI: error TS2722: Cannot invoke an object which is possibly 'undefined' (also at line 81). handlers?. only guards the handlers object; the indexed member handlers['transcript.chunk'] is still optional under the useSSE handler-map type, so the call expression stays unchecked.
The constraint: npm --prefix frontend run build has to pass. A few ways to satisfy it — pick whichever reads best to you:
- Pull the handler into a local and assert it exists first, which also gives the test a real assertion that the handler was registered:
const handler = useSSEMock.mock.calls.at(-1)?.[1].handlers['transcript.chunk']; expect(handler).toBeDefined(); handler!({ text: 'streamed row\n' }). - Add a small typed accessor helper used by both
actblocks that narrows once and throws on a missing handler. - Non-null assert the index directly — shortest, but it drops the "handler was registered" signal.
Test to cover it: this same test (hands a live transcript from the first chunk to SSE until finished) — no new test needed, just make it typecheck. Confirm with npm --prefix frontend run build before pushing.
Push back if you see a cleaner narrowing I've missed.
| "offset": 0, | ||
| "nextOffset": 5, | ||
| "eof": False, | ||
| "text": "hello", |
There was a problem hiding this comment.
AC-1 names the derived-abandoned case explicitly: "adds Cache-Control: public, max-age=31536000, immutable … for effectively terminal agent calls, including calls derived as abandoned." The two tests here cover the two stored shapes only — finish_agent_run sets finished_at plus status=succeeded, so get_live_status() returns from its first branch, and the finished=False test leaves the run active so it returns "running". The third branch of AgentCall.get_live_status() (finished_at unset, self.run.is_active false → "abandoned") has no route-level test, and that is the branch the plan singled out as the reason to use get_live_status() instead of the stored status column.
Constraint: a route test that exercises the abandoned derivation and asserts the immutable header. Extending _seed_run with a way to end the owning run without setting the call's finished_at is one route; adding a separate seeding helper alongside finish_agent_run in conftest.py is another — your call which fits the existing conftest conventions better.
Test to add: a third case next to test_transcript_range_fetch_running_call_disables_cache, e.g. test_transcript_range_fetch_abandoned_call_is_immutable.
Disagree freely if you read AC-1's verification clause ("finished and running calls") as deliberately narrower than its description.
Superseded by pushed implementation revision.
There was a problem hiding this comment.
Summary
Round 2 cleared one of the two prior blockers and broke the backend suite with the fix for the other. The frontend typecheck failure is gone — tsc -b && vite build, eslint, and vitest run are all green (run 30177967979). The new derived-abandoned backend test, added to close prior blocker #2, fails: it updates dbos.workflow_status keyed by the AgentCall id instead of the run id, so no row changes and the route returns no-store as it should. Backend CI is red (1 failed, 947 passed, 1 skipped, run 30177967977).
Prior blockers
- Resolved — "
handlers?.['transcript.chunk'](...)guards only thehandlersobject, not the indexed member, sotscrejects the call expression." Optional-call syntax landed on both handlers; the frontend job now passestsc -b && vite build. - Addressed but failing — "no route test for the derived-abandoned branch of
get_live_status()". The test exists, but it does not exercise the branch and it fails. Details inline onbackend/tests/test_api_runs.py:125.
What holds up
- AC-1 route behaviour.
get_transcriptinjectsResponse, loads the call withAgentCall.get(call_id), and branches oncall.get_live_status() == AgentCallStatus.RUNNING—no-storewhile running,public, max-age=31536000, immutableotherwise. Path, query params,TranscriptChunkmodel, and camelCase aliasing are untouched;/streamstill usesSSE_HEADERS. The stored-terminal and running tests pass and keep their pagination/camelCase assertions. - AC-2.
setInitialpublishes inside the pagination loop under thecancelledguard, and the!isLivebranch rendersStreamTranscriptfrom the accumulated state withcomplete={initial.eof}. ThereceivedChunkflag preserves the prior no-chunk fallback. The test holds the second fetch unresolved, asserts the first row renders, then resolves and asserts both. - AC-3. Live mode still exits pagination after one fetch, pins the SSE URL at that chunk's
nextOffset, appendstranscript.chunk, and flipsenabledto false onagent_call.finished. The test asserts all four. - Sweeps clean. No dependency, lockfile, migration, or config change. None of the ruled-out approaches appear.
offset/limitvalidation is unchanged.
Blocking findings
backend/tests/test_api_runs.py:125— the derived-abandoned test targets the wrong id, matches noworkflow_statusrow, and fails CI. See the inline comment for the two fix options.
Verification
uv run ruff check backend— passed locally.- Node is not installed in this sandbox and Postgres is unavailable, so the frontend commands and
uv run pytest backend/could not run here. I used the PR's CI: frontend (30177967979) green across lint, test, and build; backend (30177967977) red on the one new test.
Follow-up recommendations
get_transcriptraises a second 404 whenAgentCall.get(call_id)isNone, butreads.read_transcript_chunkalready returnsNonefor a missing call, so that branch is unreachable today. Harmless defensive code; worth a comment or removal in a separate ticket.- The immutable header reflects status at read time. A chunk fetched between the call turning terminal and the last bytes landing on disk would be cached for a year. The window is tiny and the ticket accepts the trade; noted for the record.
Disagreement welcome — if you think the test is failing for a different reason than the id mismatch, say so and we'll work from your reading.
| run_id = _seed_run(tmp_path=tmp_path, finished=False) | ||
| db_session.execute( | ||
| update(workflow_status) | ||
| .where(workflow_status.c.workflow_uuid == run_id) |
There was a problem hiding this comment.
This new test fails in CI: FAILED test_transcript_range_fetch_derived_abandoned_call_uses_immutable_cache - AssertionError: assert 'no-store' == 'public, max-...00, immutable' (run 30177967977, 1 failed, 947 passed).
The UPDATE matches zero rows. _seed_run returns seed_agent_run().id, which is the AgentCall id, while dbos.workflow_status.workflow_uuid is keyed by the run/workflow id (call.run_id). With no row updated, Run.state stays running, AgentCall.get_live_status() returns running, and the route correctly emits no-store.
The constraint: the test must drive the owning run into a terminal state before the request, so get_live_status() derives abandoned. Two reasonable ways:
- Update the same table but key on the run id — e.g. capture the call via
AgentCall.get(call_id)(or have_seed_runreturn both ids) and filter onworkflow_status.c.workflow_uuid == call.run_id. - Seed the run terminal up front instead of mutating it:
seed_build_run(session, work_item_id=..., state="failed")already routes throughconftest.seed_dbos_status, which owns thefailed→ERRORmapping. That keeps the status mapping in one place rather than hard-coding"ERROR"here.
I'd lean toward the second if _seed_run can take a state argument cleanly, since it reuses the existing helper; the first is fine if you want a smaller diff. Your call.
Test to land: this same test_transcript_range_fetch_derived_abandoned_call_uses_immutable_cache case, green — it is the AC-1 verification for "including calls derived as abandoned". Please confirm the run's derived state is actually terminal (assert it, or assert the header only after a get_live_status()-backed precondition) so the test can't pass for the wrong reason.
Push back if you read the id plumbing differently — I traced it from _seed_run → seed_agent_run → seed_build_run and may have missed a path.
Superseded by commit 452d7ba addressing the review finding.
There was a problem hiding this comment.
Summary
Round 3 closes the last outstanding blocker. Both CI jobs are green on 452d7ba: backend run 30178130676 (ruff, pytest backend/, and the proof-extension suite all pass) and frontend run 30178130675 (eslint, vitest 53 passed across 11 files, tsc -b && vite build). All three acceptance criteria are satisfied. Approving.
Prior blockers
- Resolved (round 2) — "
handlers?.['transcript.chunk'](...)guards only thehandlersobject, not the indexed member, sotscrejects the call expression." Optional-call syntax landed on both handlers; the build step passes. - Resolved (round 3) — "no route test for the derived-abandoned branch of
get_live_status()", then "it updatesdbos.workflow_statuskeyed by the AgentCall id instead of the owning run id, so no row changes."backend/tests/test_api_runs.py:117-127now loads the call withAgentCall.get(call_id)and keys theupdate()oncall.run_id, so the owning workflow really does go toERROR. The test passes in CI, which means the route genuinely reached the derived-abandoned branch and emitted the immutable header — a no-op update would have producedno-storeand failed the assertion, as it did last round.
Acceptance criteria
- AC-1 — pass.
get_transcriptinbackend/druks/extensions/base.pyinjects FastAPI'sResponse, loads the call viaAgentCall.get(call_id)as the plan specified, and branches oncall.get_live_status() == AgentCallStatus.RUNNING:no-storewhile running,public, max-age=31536000, immutableotherwise. Path, query params, theTranscriptChunkresponse model, and camelCase aliasing are untouched;/streamstill usesSSE_HEADERS. Three route tests now cover the stored-terminal, running, and derived-abandoned branches with the exact header strings, and the pagination/camelCase assertions are intact. - AC-2 — pass.
setInitialpublishes inside the pagination loop under the existingcancelledguard, and the new!isLivebranch rendersStreamTranscriptfrom the accumulated state withcomplete={initial.eof}— that is what makes later publications visible instead of being swallowed byRunTranscriptLive's one-timeuseState(initialText)initializer. ThereceivedChunkflag preserves the prior no-chunk fallback. The test holds the second fetch unresolved, asserts the first row renders, then resolves and asserts both. - AC-3 — pass. Live mode still exits pagination after one fetch, pins the SSE URL at that chunk's
nextOffset, appendstranscript.chunk, and flipsenabledto false onagent_call.finished. The test asserts each of those.
Sweeps
No dependency, lockfile, migration, generated-file, or config change — the PR touches four files. None of the ruled-out approaches (plain-text terminal endpoint, app-level cache, ETag revalidation, parallel pagination, row virtualization) appear. offset/limit validation and the 404 paths are unchanged.
Follow-up recommendations
These are notes for a separate ticket, not changes required here:
get_transcriptraises a second 404 whenAgentCall.get(call_id)isNone, butreads.read_transcript_chunkalready returnsNonefor a missing call, so that branch is unreachable today. Harmless defensive code.- The immutable header reflects status at read time. A chunk fetched in the window between the call turning terminal and its last bytes landing on disk would be cached for a year. The window is small and the ticket accepts the trade-off; if you ever want to close it, gating on
finished_atplus a settle margin or omitting the header wheneofis false are both plausible — worth measuring first. _seed_runreturns an AgentCall id but the older tests still name the variablerun_id. The round-3 test'scall_idnaming is clearer; renaming the rest is cosmetic.
If you disagree with any of the follow-ups — particularly the caching-window note — push back; none of them block this PR and I would rather hear the counter-argument than have them filed reflexively.
|
Code review: This PR cleanly delivers both payoff items — immutable browser caching for terminal transcript chunks and progressive per-chunk rendering — following the repo's existing |
The route fetched the chunk, 404'd, then fetched the call and 404'd again with the same message — two queries guarding one condition. It now finds the call first and asks it how cacheable its transcript is, so read_transcript_chunk takes the call it already has and stops carrying a not-found arm nobody reads. receivedChunk existed only so a first-fetch failure still cleared "loading". The loop condition says that directly. The non-live path returns the static transcript, which retires RunTranscriptLive's isLive/initialEof plumbing. The abandoned-call test seeds a failed run instead of writing DBOS's status table by hand.
Linear ticket: ENG-756
Plan
Scope and decisions
Implement the two required payoff items as one focused backend/frontend change: immutable browser caching for terminal transcript chunks and progressive rendering during the first terminal download. Preserve the current paginated JSON protocol and the live one-chunk-to-SSE handoff. Treat
AgentCall.get_live_status()as the status source so an unfinished row whose run has become terminal is cached as the effectively terminalabandonedcall that the read side already exposes.Implementation
Update
backend/druks/extensions/base.pyinExtension._get_transcript_routes().Responseintoget_transcriptwithout changing its path, query parameters, response model, or camelCase serialization.reads.read_transcript_chunk(...), load the call with the existingAgentCall.get(call_id)model API used by the neighboring file route.Cache-Control: public, max-age=31536000, immutablewhencall.get_live_status()is anything other thanAgentCallStatus.RUNNING; this includes storedsucceeded/failed/abandonedcalls and calls derived as abandoned because their owning run is terminal.Cache-Control: no-storewhile the effective status isrunning, because its transcript file can still grow./stream; it continues to useSSE_HEADERSwithCache-Control: no-storeandX-Accel-Buffering: no.Extend the transcript route coverage in
backend/tests/test_api_runs.py.Cache-Control: no-storewhile retaining the normalTranscriptChunkresponse.Update
frontend/src/components/RunTranscript.tsxso terminal downloads render incrementally.offset/nextOffsetpagination and 256 KiB limit, but callsetInitialafter every successfully decoded chunk with the accumulated text, new offset, and currenteof, guarded by the existing cancellation flag.isLive === false, renderStreamTranscriptdirectly from the progressively updated accumulated state withcomplete={initial.eof}. This ensures subsequent parent-state publications append visibly instead of being lost inRunTranscriptLive's one-timeuseState(initialText)initializer.RunTranscriptLiveas the live-only handoff: live mode still exits pagination after one fetch, opens${basePath}/stream?stream=${stream}&offset=${initial.nextOffset}, appendstranscript.chunk, and disables SSE onagent_call.finished.Add
frontend/src/components/RunTranscript.test.tsxusing the repository's Vitest and Testing Library conventions.eof: falseand whose second response is deliberately unresolved; assert the first complete row renders before resolving the second request, then resolve it and assert the later row appends.nextOffset, atranscript.chunkhandler appends text, andagent_call.finishedchanges the subscription to disabled. Rely on the existingfrontend/src/api/sse.test.tsxcoverage for the resulting EventSource close behavior.Wire contract
Terminal chunk response:
Running chunk response uses the same body contract but explicitly prevents caching:
Out of scope
StreamTranscriptrow virtualization remains out of scope. As the ticket states, measure it before acting rather than assuming it is the main cost.Acceptance criteria
AC-1
Description: The paginated transcript endpoint keeps its existing
TranscriptChunkJSON contract and addsCache-Control: public, max-age=31536000, immutableto successful responses for effectively terminal agent calls, including calls derived as abandoned; successful responses for running calls useCache-Control: no-store.Verification: Backend route tests create finished and running calls, assert the exact cache header for each, and retain assertions for the camelCase chunk fields and pagination values.
AC-2
Description: For a non-live transcript,
RunTranscriptpublishes the accumulated text after every successful chunk: content from the first chunk is rendered while a later chunk request is still pending, and later chunks append when they arrive.Verification: A React component test holds the second fetch promise unresolved, asserts first-chunk content is already rendered, then resolves the promise and asserts both chunks are rendered.
AC-3
Description: For a live transcript,
RunTranscriptstill performs one initial chunk fetch, hands that chunk'snextOffsetto the/streamSSE URL, appendstranscript.chunkpayloads, and disables the SSE subscription afteragent_call.finished.Verification: A focused
RunTranscripttest asserts one paginated fetch, the offset-pinned SSE handoff, appended streamed text, and the enabled-to-disabled transition on the terminal event; the existinguseSSEtest continues to cover connection closure when disabled.Ruled out
StreamTranscriptrows in this PR: the ticket identifies rendering as secondary and explicitly requires measurement before acting.Acceptance Criteria
TranscriptChunkJSON contract and addsCache-Control: public, max-age=31536000, immutableto successful responses for effectively terminal agent calls, including calls derived as abandoned; successful responses for running calls useCache-Control: no-store.RunTranscriptpublishes the accumulated text after every successful chunk: content from the first chunk is rendered while a later chunk request is still pending, and later chunks append when they arrive.RunTranscriptstill performs one initial chunk fetch, hands that chunk'snextOffsetto the/streamSSE URL, appendstranscript.chunkpayloads, and disables the SSE subscription afteragent_call.finished.RunTranscripttest asserts one paginated fetch, the offset-pinned SSE handoff, appended streamed text, and the enabled-to-disabled transition on the terminal event; the existinguseSSEtest continues to cover connection closure when disabled.