Skip to content

feat(playground): resume calls and replay audio - #3320

Merged
steebchen merged 3 commits into
theopenco:mainfrom
RATCHAW:RATCHAW/resume-call-same-context
Jul 31, 2026
Merged

steebchen merged 3 commits into
theopenco:mainfrom
RATCHAW:RATCHAW/resume-call-same-context

Conversation

@RATCHAW

@RATCHAW RATCHAW commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Realtime voice calls were one-shot: a saved call could not be picked up again, and assistant speech was played once and discarded. This adds a Continue call action that replays a saved call's turns to the model as conversation items and appends the new turns back onto the same history row, plus per-turn WAV capture of the assistant's audio so every response gets a Play button both live and in history. PATCH /realtime-history/{id} is widened from rename-only to also append transcript turns and sum duration/usage server-side, covered by a new API spec; the schema change is type-only, so pnpm migrations generates no SQL. The call controls are now pinned while only the transcript scrolls, following the newest turn only when already scrolled to the bottom.

Note: this has not been exercised against a live provider call — pnpm build, pnpm lint and the unit/API tests all pass, but the seeded-replay item shape and audio playback are verified by tests and code review only.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Realtime call history can now store and play back assistant audio for individual transcript turns.
    • Continue existing calls by replaying prior turns and appending new conversation data.
    • Call history updates now support titles, transcript additions, duration, and usage totals.
  • Bug Fixes

    • Prevented starting a new call while another call is active.
    • Improved transcript scrolling and playback state handling.
  • Tests

    • Added coverage for audio conversion, call history updates (aggregation/validation), and authorization scoping.

Realtime voice calls were one-shot: a saved call could not be picked up
again, and assistant speech was played once and discarded.

- Accumulate the assistant's PCM deltas per turn, encode to WAV, and
  store them base64 on the transcript entries so each response gets a
  Play button live and in history.
- Add "Continue call", which replays a saved call's turns to the model
  as conversation items and appends the new turns back onto the same
  history row rather than creating a second one.
- Widen PATCH /realtime-history/{id} from rename-only to also append
  transcript turns and sum duration/usage server-side.
- Keep the call controls pinned while the transcript scrolls, and
  follow the newest turn only when already scrolled to the bottom.

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The realtime playground now stores assistant WAV audio with transcript turns, supports replaying and continuing saved calls, appends new turns and usage to existing history, and provides per-turn audio playback. The API, database schema, client hooks, UI, and related tests were updated.

Changes

Realtime history audio and continuation

Layer / File(s) Summary
Audio encoding and transcript storage
apps/playground/src/lib/realtime-audio.ts, apps/playground/src/hooks/use-realtime-call.ts, packages/db/src/schema.ts, apps/api/src/routes/playground.ts, apps/playground/src/lib/realtime-audio.spec.ts
PCM audio is converted to WAV base64, attached to assistant transcript entries, persisted in realtime history, and covered by encoding and round-trip tests.
History update contract and persistence
apps/api/src/routes/playground.ts, apps/api/src/routes/playground-realtime-history.spec.ts
The PATCH endpoint supports title, transcript, duration, and usage updates, validates requests, handles missing records, and aggregates persisted values.
Seeded call replay and persistence wiring
apps/playground/src/hooks/use-realtime-call.ts, apps/playground/src/hooks/usePlaygroundHistory.ts, apps/playground/src/components/playground/realtime-page-client.tsx
Saved turns can seed a new realtime session, while newly generated turns are appended to the original history record with incremental totals.
History controls and transcript playback
apps/playground/src/components/playground/realtime-page-client.tsx, apps/playground/src/components/playground/call-history-list.tsx
The UI adds continue-call controls, per-turn play/stop controls, dedicated transcript scrolling, and uses the generalized history update mutation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Suggested reviewers: steebchen

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: resumable playground calls with audio replay.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ff3a740eb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/api/src/routes/playground.ts Outdated
Comment on lines +1460 to +1462
...(body.appendTranscript
? { transcript: [...existing.transcript, ...body.appendTranscript] }
: {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Append continuations atomically

When the same saved call is continued from two tabs or devices and both PATCH requests overlap, each request reads the same original row and then replaces the transcript, duration, and usage using that stale snapshot. The later update therefore discards the other continuation's turns and counters; use atomic JSONB/numeric update expressions or lock the row within a transaction.

Useful? React with 👍 / 👎.

Comment on lines 220 to +222
responseActiveRef.current = false;
audioChunksRef.current.clear();
audioBytesRef.current = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Finalize buffered audio before cleanup

When the user ends or navigates away during an assistant response, the PCM collected since the last response.done is cleared here before it is encoded and attached to the transcript. The text is still persisted, but the final assistant turn permanently loses its replay audio; finalize the pending chunks before clearing them during call teardown.

Useful? React with 👍 / 👎.

Comment on lines +390 to +392
for (const entry of seed ?? []) {
if (entry.status === "partial" || !entry.text.trim()) {
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude unheard interrupted output from replay

After barge-in, the original session is truncated to playedMs, but the transcript entry is only marked interrupted and can still contain output that was queued but never heard. This filter skips only partial entries, so a continuation sends the entire interrupted transcript back as output_text, giving the new session context that the user never heard and that the prior session explicitly removed; preserve a truncated version or avoid replaying that unheard tail.

Useful? React with 👍 / 👎.

Comment on lines +764 to +767
reset();
seedRef.current = seed?.length ? seed : null;
if (seed?.length) {
setTranscript(seed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Count seeded audio toward the retention ceiling

Each continued session loads all previously saved WAVs into seed while starting its audio counter from zero, allowing another 24 MiB to be appended every time the call is resumed. Repeated continuations can therefore create an unbounded JSONB row and increasingly large history GET responses despite the stated ceiling; initialize the budget from the seeded audio or enforce a cumulative server-side limit.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
apps/api/src/routes/playground.ts (2)

1145-1152: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Unbounded cumulative growth of the transcript jsonb row.

Per-entry base64 is capped at ~48 MB, but nothing caps the accumulated transcript. Each continuation appends another session's worth of WAV base64 to the same row, and the PATCH handler below reads the whole transcript into memory and rewrites it, so a repeatedly-continued call grows the row plus every read/write payload without limit. Consider a server-side ceiling on total stored audio bytes per call (or offloading audio to blob storage keyed from the transcript entry).

🤖 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/api/src/routes/playground.ts` around lines 1145 - 1152, The transcript
schema’s optional audio field allows unbounded cumulative WAV growth across
continuations. Add a server-side per-call limit for total stored audio bytes and
enforce it in the PATCH/continuation handler before appending or rewriting the
transcript, rejecting updates that exceed the limit while preserving existing
per-entry validation.

1435-1487: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Make realtime-history append updates atomic.

durationSeconds and usage are currently recomputed from the pre-loaded row, so a retried/overlapping patch can lose updates. Use SQL increments/aggregation for these fields and let the .returning() 404 confirm the row exists without keeping a separate pre-read.

🤖 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/api/src/routes/playground.ts` around lines 1435 - 1487, Make the
realtime-history update in the PATCH handler atomic by removing the preliminary
select and computing durationSeconds and usage from their current database
values within the update expression. Preserve transcript/title updates, use SQL
increments/aggregation for addDurationSeconds and addUsage, and rely on the
update’s returning() result to throw the existing 404 when no authorized row
exists.
🤖 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/api/src/routes/playground-realtime-history.spec.ts`:
- Around line 139-142: Update the test around patchCall so it verifies
ownership: create a playground call under a second user, then attempt to PATCH
that existing call using the first user’s token and assert the 404 response.
Keep the test name aligned with this cross-user ownership scenario and retain
the existing missing-row case separately if needed.

In `@apps/playground/src/hooks/use-realtime-call.ts`:
- Around line 380-408: Add explicit error handling for seed-replay
conversation.item.create events in the continued-call flow around seedRef and
sendEvent. Track replay failures from Realtime error events, log or surface the
failure, and prevent the session from being treated as healthy or transitioning
to "live" after a failed replay; preserve the existing skip rules and successful
replay behavior.

---

Nitpick comments:
In `@apps/api/src/routes/playground.ts`:
- Around line 1145-1152: The transcript schema’s optional audio field allows
unbounded cumulative WAV growth across continuations. Add a server-side per-call
limit for total stored audio bytes and enforce it in the PATCH/continuation
handler before appending or rewriting the transcript, rejecting updates that
exceed the limit while preserving existing per-entry validation.
- Around line 1435-1487: Make the realtime-history update in the PATCH handler
atomic by removing the preliminary select and computing durationSeconds and
usage from their current database values within the update expression. Preserve
transcript/title updates, use SQL increments/aggregation for addDurationSeconds
and addUsage, and rely on the update’s returning() result to throw the existing
404 when no authorized row exists.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fc8beeac-6dd8-4467-92f7-232a9423867f

📥 Commits

Reviewing files that changed from the base of the PR and between f4e89b3 and 6ff3a74.

📒 Files selected for processing (9)
  • apps/api/src/routes/playground-realtime-history.spec.ts
  • apps/api/src/routes/playground.ts
  • apps/playground/src/components/playground/call-history-list.tsx
  • apps/playground/src/components/playground/realtime-page-client.tsx
  • apps/playground/src/hooks/use-realtime-call.ts
  • apps/playground/src/hooks/usePlaygroundHistory.ts
  • apps/playground/src/lib/realtime-audio.spec.ts
  • apps/playground/src/lib/realtime-audio.ts
  • packages/db/src/schema.ts

Comment thread apps/api/src/routes/playground-realtime-history.spec.ts
Comment on lines +380 to +408
// Replay a continued call's prior turns before going live, so the
// model has the earlier conversation in context. Fire-and-forget
// is safe: microphone chunks are dropped until the status flips
// to "live" below, so nothing can race ahead of the seed.
// Only the text is replayed — the realtime API cannot accept
// assistant audio items — and the content type is the role's
// side of the GA enum: input_text for user, output_text for
// assistant.
const seed = seedRef.current;
seedRef.current = null;
for (const entry of seed ?? []) {
if (entry.status === "partial" || !entry.text.trim()) {
continue;
}
sendEvent({
type: "conversation.item.create",
item: {
type: "message",
role: entry.role,
content: [
{
type:
entry.role === "user" ? "input_text" : "output_text",
text: entry.text,
},
],
},
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

OpenAI Realtime API conversation.item.create assistant message content type output_text

💡 Result:

In the OpenAI Realtime API, when using the conversation.item.create event to add a message, the content type used for text messages differs based on the role of the message sender [1][2]. For conversation.item.create, the valid content types are [1][3]: - For user role messages: Use the input_text content type [4][1][2]. - For system role messages: Use the input_text content type [1][2]. - For assistant role messages: The Realtime API has a limitation regarding conversation.item.create in that it cannot be used to populate assistant audio messages [5][6][7]. While assistant messages generally support text content type in other contexts, the conversation.item.create event is primarily designed for populating history with user, system, or function-related items [6][1][8]. The output_text type is generally associated with model-generated output and is not typically used as a valid content type for a client-sent conversation.item.create event [2][3]. When manually creating a message item, ensure you are using the appropriate input_text type for user/system inputs [4][1].

Citations:


🌐 Web query:

OpenAI Realtime conversation.item.create assistant message output_text documentation

💡 Result:

The conversation.item.create event in the OpenAI Realtime API is used to add items—such as messages, function calls, or function call responses—to a conversation's context [1][2]. Regarding the use of assistant messages with output_text: 1. Limitation on Assistant Messages: Official documentation and SDK definitions state that the conversation.item.create event has a current limitation: it cannot be used to populate assistant audio messages [1][3][4]. 2. Structure for Text Content: When constructing a message item, the content is defined as an array of objects [5]. If you are working with text output, the content object typically includes a type field (e.g., 'output_text') and a text field for the actual string content [5]. 3. Intended Use: While the client can send conversation.item.create to populate conversation history or add mid-stream items, this event is generally intended for user messages or function outputs [1][2]. When the model generates a response (which may include assistant messages with text), it is triggered by the server in response to other events (such as response.create) or user input [6][2]. In summary, while you can structure messages with text content in your application code [5], you cannot use conversation.item.create to "inject" an assistant-role message into the conversation history as if it were a model-generated turn [1][4]. The system is designed for the model to generate assistant responses automatically based on the conversation context [6].

Citations:


🌐 Web query:

site:openai.com/developers realtime conversation.item.create assistant output_text

💡 Result:

OpenAI’s Realtime API docs say conversation.item.create can add messages to the conversation, and the created item can be an assistant message with content.type set to output_text when the session is configured for text output. The same docs note it cannot populate assistant audio messages. (platform.openai.com)

If you want, I can also pull the exact JSON shape for a working conversation.item.create assistant text item.

Citations:


Replay error handling for seed-created turns.

The input_text / output_text pairing is the current Realtime shape for created message text content, but seed-replay conversation.item.create events are fire-and-forget; a failed replay still leaves session.updated / live unchanged unless error events are handled. Add logging or explicit handling for failed replay items so session.updated is not treated as a healthy continued call.

🤖 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/playground/src/hooks/use-realtime-call.ts` around lines 380 - 408, Add
explicit error handling for seed-replay conversation.item.create events in the
continued-call flow around seedRef and sendEvent. Track replay failures from
Realtime error events, log or surface the failure, and prevent the session from
being treated as healthy or transitioning to "live" after a failed replay;
preserve the existing skip rules and successful replay behavior.

RATCHAW and others added 2 commits July 30, 2026 14:25
Address review findings on call continuation:

- Serialize concurrent PATCH continuations of the same saved call
  with a row lock so neither overwrites the other's appended turns
- Finalize buffered assistant audio during call teardown so ending
  mid-response keeps the final turn's replay clip
- Count seeded audio toward the per-call audio budget so repeated
  continuations cannot grow one saved conversation without bound
- Exercise the cross-user ownership path in the PATCH 404 test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/api/src/routes/playground.ts (1)

1145-1153: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the realtime call request bodies.

transcript and appendTranscript only enforce min(1), so a single POST/PATCH can contain arbitrarily many entries, each allowing up to 48MB of base64 audio. Add a reasonable max transcription entry count and/or an aggregate audio byte budget in the server schema before storing the joined result.

🤖 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/api/src/routes/playground.ts` around lines 1145 - 1153, Update the
realtime call request schema in playground route to bound transcript and
appendTranscript payloads before persistence: add a reasonable maximum entry
count and enforce an aggregate audio-size budget across their optional
audio.base64 values, while preserving the existing per-entry validation and
joined-result behavior.

Source: Coding guidelines

🧹 Nitpick comments (2)
apps/api/src/routes/playground.ts (2)

1395-1409: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

addUsage accepts negative/fractional counters.

realtimeUsageSchema uses bare z.number(), so a client can PATCH negative or fractional token counts that are summed into the stored totals. addDurationSeconds is correctly guarded with .int().min(0); consider a nonnegative-int variant for the additive usage payload.

🤖 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/api/src/routes/playground.ts` around lines 1395 - 1409, Update the
addUsage validation in the PATCH schema around realtimeUsageSchema so every
additive usage counter is constrained to nonnegative integers, matching the
existing addDurationSeconds validation. Preserve the optional-field behavior and
ensure invalid negative or fractional usage values are rejected before totals
are updated.

1439-1449: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Locked read pulls the whole transcript (incl. base64 audio) even when not appending.

tx.select() fetches transcript — potentially tens of MB of WAV base64 — for every PATCH, including title-only renames and usage/duration-only updates. Narrow the projection and only pull transcript when body.appendTranscript is present (or append in SQL via transcript || $new::jsonb), which also lets the row-locked read stay cheap.

♻️ Sketch: conditional projection
-		const [existing] = await tx
-			.select()
+		const [existing] = await tx
+			.select({
+				id: tables.playgroundRealtimeHistory.id,
+				durationSeconds: tables.playgroundRealtimeHistory.durationSeconds,
+				usage: tables.playgroundRealtimeHistory.usage,
+				...(body.appendTranscript
+					? { transcript: tables.playgroundRealtimeHistory.transcript }
+					: {}),
+			})
 			.from(tables.playgroundRealtimeHistory)
🤖 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/api/src/routes/playground.ts` around lines 1439 - 1449, Update the
transaction query in the PATCH handler around the playgroundRealtimeHistory
lookup to avoid selecting the full transcript for non-append updates: use a
narrow projection for metadata and include transcript only when
body.appendTranscript is present, or append the new entries in SQL while holding
the row lock. Preserve the existing locked-row update behavior for title, usage,
and duration changes.
🤖 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 `@apps/api/src/routes/playground.ts`:
- Around line 1145-1153: Update the realtime call request schema in playground
route to bound transcript and appendTranscript payloads before persistence: add
a reasonable maximum entry count and enforce an aggregate audio-size budget
across their optional audio.base64 values, while preserving the existing
per-entry validation and joined-result behavior.

---

Nitpick comments:
In `@apps/api/src/routes/playground.ts`:
- Around line 1395-1409: Update the addUsage validation in the PATCH schema
around realtimeUsageSchema so every additive usage counter is constrained to
nonnegative integers, matching the existing addDurationSeconds validation.
Preserve the optional-field behavior and ensure invalid negative or fractional
usage values are rejected before totals are updated.
- Around line 1439-1449: Update the transaction query in the PATCH handler
around the playgroundRealtimeHistory lookup to avoid selecting the full
transcript for non-append updates: use a narrow projection for metadata and
include transcript only when body.appendTranscript is present, or append the new
entries in SQL while holding the row lock. Preserve the existing locked-row
update behavior for title, usage, and duration changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3305284f-22a1-407b-a24f-106eb19f0231

📥 Commits

Reviewing files that changed from the base of the PR and between 6ff3a74 and 413b75f.

📒 Files selected for processing (3)
  • apps/api/src/routes/playground-realtime-history.spec.ts
  • apps/api/src/routes/playground.ts
  • apps/playground/src/hooks/use-realtime-call.ts

@steebchen
steebchen added this pull request to the merge queue Jul 31, 2026
Merged via the queue into theopenco:main with commit 5cf09e6 Jul 31, 2026
8 checks passed
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.

2 participants