Skip to content

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

Merged
druks-operator[bot] merged 5 commits into
mainfrom
agent/ENG-756
Jul 26, 2026

Conversation

@druks-operator

Copy link
Copy Markdown
Contributor

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 terminal abandoned call that the read side already exposes.

Implementation

  1. Update backend/druks/extensions/base.py in Extension._get_transcript_routes().

    • Inject FastAPI's Response into get_transcript without changing its path, query parameters, response model, or camelCase serialization.
    • After the existing validation and successful reads.read_transcript_chunk(...), load the call with the existing AgentCall.get(call_id) model API used by the neighboring file route.
    • Set Cache-Control: public, max-age=31536000, immutable when call.get_live_status() is anything other than AgentCallStatus.RUNNING; this includes stored succeeded/failed/abandoned calls and calls derived as abandoned because their owning run is terminal.
    • Set Cache-Control: no-store while the effective status is running, because its transcript file can still grow.
    • Leave validation errors and missing-call 404 behavior unchanged. Do not alter /stream; it continues to use SSE_HEADERS with Cache-Control: no-store and X-Accel-Buffering: no.
  2. Extend the transcript route coverage in backend/tests/test_api_runs.py.

    • Add the exact immutable-header assertion to the existing finished-call pagination coverage.
    • Add focused coverage for an unfinished/running seeded call and assert Cache-Control: no-store while retaining the normal TranscriptChunk response.
    • Keep the existing pagination, missing-file, SSE completion, and file-serving assertions intact.
  3. Update frontend/src/components/RunTranscript.tsx so terminal downloads render incrementally.

    • Keep the current serial offset/nextOffset pagination and 256 KiB limit, but call setInitial after every successfully decoded chunk with the accumulated text, new offset, and current eof, guarded by the existing cancellation flag.
    • Preserve the current fallback when no successful chunk is received, so a failed initial request does not introduce a new stuck state.
    • For isLive === false, render StreamTranscript directly from the progressively updated accumulated state with complete={initial.eof}. This ensures subsequent parent-state publications append visibly instead of being lost in RunTranscriptLive's one-time useState(initialText) initializer.
    • Keep RunTranscriptLive as the live-only handoff: live mode still exits pagination after one fetch, opens ${basePath}/stream?stream=${stream}&offset=${initial.nextOffset}, appends transcript.chunk, and disables SSE on agent_call.finished.
    • Update the component contract comment to describe progressive paginated backfill rather than implying that the whole log is withheld until fetched.
  4. Add frontend/src/components/RunTranscript.test.tsx using the repository's Vitest and Testing Library conventions.

    • Model a terminal transcript whose first response has eof: false and 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.
    • Cover the live branch by asserting only one initial fetch, the SSE URL starts at that response's nextOffset, a transcript.chunk handler appends text, and agent_call.finished changes the subscription to disabled. Rely on the existing frontend/src/api/sse.test.tsx coverage for the resulting EventSource close behavior.

Wire contract

Terminal chunk response:

GET /api/build/transcripts/call-123?stream=stdout&offset=0&limit=262144
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
Content-Type: application/json

{"callId":"call-123","stream":"stdout","offset":0,"nextOffset":13,"eof":true,"text":"first\nsecond\n"}

Running chunk response uses the same body contract but explicitly prevents caching:

GET /api/build/transcripts/call-456?stream=stdout&offset=0&limit=262144
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json

{"callId":"call-456","stream":"stdout","offset":0,"nextOffset":6,"eof":false,"text":"first\n"}

Out of scope

  • The optional terminal plain-text/range-serving path is not part of this PR; pagination and JSON wrapping remain compatible.
  • StreamTranscript row virtualization remains out of scope. As the ticket states, measure it before acting rather than assuming it is the main cost.
  • No app-level transcript cache, new dependency, database change, or public documentation change is needed.

Acceptance criteria

AC-1

Description: The paginated transcript endpoint keeps its existing TranscriptChunk JSON contract and adds Cache-Control: public, max-age=31536000, immutable to successful responses for effectively terminal agent calls, including calls derived as abandoned; successful responses for running calls use Cache-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, RunTranscript publishes 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, RunTranscript still performs one initial chunk fetch, hands that chunk's nextOffset to the /stream SSE URL, appends transcript.chunk payloads, and disables the SSE subscription after agent_call.finished.

Verification: A focused RunTranscript test asserts one paginated fetch, the offset-pinned SSE handoff, appended streamed text, and the enabled-to-disabled transition on the terminal event; the existing useSSE test continues to cover connection closure when disabled.

Ruled out

  • Collapse terminal transcripts into a new plain-text/range endpoint: the ticket marks this optional, and it would expand the wire-contract and client changes beyond the two required fixes.
  • Add a React Query, module-level, or component-lifetime transcript cache: terminal HTTP responses are immutable, so the browser cache provides reuse across unmounts without application cache state.
  • Use ETag or Last-Modified revalidation as the primary terminal policy: revalidation still incurs a network request, while the call-specific immutable URL supports the ticket's zero-network repeat-view behavior.
  • Parallelize terminal pagination or increase the chunk limit: progressive publication removes the first-paint stall without changing pagination semantics, and immutable caching removes repeat-download cost.
  • Virtualize StreamTranscript rows in this PR: the ticket identifies rendering as secondary and explicitly requires measurement before acting.

Acceptance Criteria

  • AC-1: The paginated transcript endpoint keeps its existing TranscriptChunk JSON contract and adds Cache-Control: public, max-age=31536000, immutable to successful responses for effectively terminal agent calls, including calls derived as abandoned; successful responses for running calls use Cache-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: For a non-live transcript, RunTranscript publishes 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: For a live transcript, RunTranscript still performs one initial chunk fetch, hands that chunk's nextOffset to the /stream SSE URL, appends transcript.chunk payloads, and disables the SSE subscription after agent_call.finished.
    • Verification: A focused RunTranscript test asserts one paginated fetch, the offset-pinned SSE handoff, appended streamed text, and the enabled-to-disabled transition on the terminal event; the existing useSSE test continues to cover connection closure when disabled.

@druks-reviewer druks-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_transcript injects Response, reads the call through AgentCall.get(call_id) as the plan specified, and branches on call.get_live_status() == AgentCallStatus.RUNNINGno-store while running, public, max-age=31536000, immutable otherwise. The TranscriptChunk response model, camelCase aliasing, path, and query params are untouched, and /stream still uses SSE_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). setInitial now fires inside the pagination loop under the cancelled guard, and the !isLive branch renders StreamTranscript directly from the accumulated state with complete={initial.eof} — which is what makes later publications visible rather than swallowed by RunTranscriptLive's one-time useState(initialText). The receivedChunk flag 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, appends transcript.chunk, and flips enabled to false on agent_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

  1. frontend/src/components/RunTranscript.test.tsx:75 and :81 — TS2722, CI-failing. Inline comment has the detail and options.
  2. 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, and npm run build could not run here; Postgres is likewise unavailable, so uv 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 job eslint and vitest run are green (53 passed, including both new RunTranscript tests) while tsc -b && vite build fails 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_transcript raises a second 404 when AgentCall.get(call_id) returns None, but reads.read_transcript_chunk already returns None for a missing call, so that branch is unreachable in practice. Harmless, and it keeps the route honest if reads ever 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' })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 act blocks 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.

Comment thread backend/tests/test_api_runs.py Outdated
"offset": 0,
"nextOffset": 5,
"eof": False,
"text": "hello",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@druks-operator
druks-operator Bot dismissed druks-reviewer[bot]’s stale review July 25, 2026 22:38

Superseded by pushed implementation revision.

@druks-reviewer druks-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Resolved — "handlers?.['transcript.chunk'](...) guards only the handlers object, not the indexed member, so tsc rejects the call expression." Optional-call syntax landed on both handlers; the frontend job now passes tsc -b && vite build.
  2. 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 on backend/tests/test_api_runs.py:125.

What holds up

  • AC-1 route behaviour. get_transcript injects Response, loads the call with AgentCall.get(call_id), and branches on call.get_live_status() == AgentCallStatus.RUNNINGno-store while running, public, max-age=31536000, immutable otherwise. Path, query params, TranscriptChunk model, and camelCase aliasing are untouched; /stream still uses SSE_HEADERS. The stored-terminal and running tests pass and keep their pagination/camelCase assertions.
  • AC-2. setInitial publishes inside the pagination loop under the cancelled guard, and the !isLive branch renders StreamTranscript from the accumulated state with complete={initial.eof}. The receivedChunk flag 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, appends transcript.chunk, and flips enabled to false on agent_call.finished. The test asserts all four.
  • Sweeps clean. No dependency, lockfile, migration, or config change. None of the ruled-out approaches appear. offset/limit validation is unchanged.

Blocking findings

  1. backend/tests/test_api_runs.py:125 — the derived-abandoned test targets the wrong id, matches no workflow_status row, 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_transcript raises a second 404 when AgentCall.get(call_id) is None, but reads.read_transcript_chunk already returns None for 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.

Comment thread backend/tests/test_api_runs.py Outdated
run_id = _seed_run(tmp_path=tmp_path, finished=False)
db_session.execute(
update(workflow_status)
.where(workflow_status.c.workflow_uuid == run_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_run return both ids) and filter on workflow_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 through conftest.seed_dbos_status, which owns the failedERROR mapping. 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_runseed_agent_runseed_build_run and may have missed a path.

@druks-operator
druks-operator Bot dismissed druks-reviewer[bot]’s stale review July 25, 2026 22:44

Superseded by commit 452d7ba addressing the review finding.

@druks-reviewer druks-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Resolved (round 2) — "handlers?.['transcript.chunk'](...) guards only the handlers object, not the indexed member, so tsc rejects the call expression." Optional-call syntax landed on both handlers; the build step passes.
  2. Resolved (round 3) — "no route test for the derived-abandoned branch of get_live_status()", then "it updates dbos.workflow_status keyed by the AgentCall id instead of the owning run id, so no row changes." backend/tests/test_api_runs.py:117-127 now loads the call with AgentCall.get(call_id) and keys the update() on call.run_id, so the owning workflow really does go to ERROR. 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 produced no-store and failed the assertion, as it did last round.

Acceptance criteria

  • AC-1 — pass. get_transcript in backend/druks/extensions/base.py injects FastAPI's Response, loads the call via AgentCall.get(call_id) as the plan specified, and branches on call.get_live_status() == AgentCallStatus.RUNNING: no-store while running, public, max-age=31536000, immutable otherwise. Path, query params, the TranscriptChunk response model, and camelCase aliasing are untouched; /stream still uses SSE_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. setInitial publishes inside the pagination loop under the existing cancelled guard, and the new !isLive branch renders StreamTranscript from the accumulated state with complete={initial.eof} — that is what makes later publications visible instead of being swallowed by RunTranscriptLive's one-time useState(initialText) initializer. The receivedChunk flag 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, appends transcript.chunk, and flips enabled to false on agent_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_transcript raises a second 404 when AgentCall.get(call_id) is None, but reads.read_transcript_chunk already returns None for 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_at plus a settle margin or omitting the header when eof is false are both plausible — worth measuring first.
  • _seed_run returns an AgentCall id but the older tests still name the variable run_id. The round-3 test's call_id naming 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.

@druks-operator

Copy link
Copy Markdown
Contributor Author

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 response.headers["Cache-Control"] idiom (matches routes.py/build/routes.py) and the plan's stated approach exactly, with behavior-focused tests on both sides and no scope creep.

@druks-operator
druks-operator Bot marked this pull request as ready for review July 25, 2026 22:50
@druks-operator
druks-operator Bot requested a review from czpython as a code owner July 25, 2026 22:50
exe.dev user and others added 5 commits July 26, 2026 06:46
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.
@druks-operator
druks-operator Bot merged commit 5c0283a into main Jul 26, 2026
2 checks passed
@druks-operator
druks-operator Bot deleted the agent/ENG-756 branch July 26, 2026 00:01
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