Repository navigation
fix(core): Reconcile playback in a bounded ledger and report unheard audio - #68
Merged
Merged
Conversation
What the user actually heard was tracked in five structures that changed at different times: #audioItems, #trailingAudio, #played, #interruptedItems and #untruncated. PlaybackLedger keeps one record per audio item, with an explicit phase (live, trailing, cut, interrupted), the played and received milliseconds and the original text. The ledger only decides: received(), report(), interrupt(), endTurn() and userTookFloor() return the truncations, and AgentSession applies them to the model and the transcript, as ToolStreams does for tool lifecycles. It is unit-tested without a model. Behaviour is unchanged and every existing test passes as is. The one difference: a report for an item the session never received audio for is ignored instead of being stored.
Every interruption left entries behind for good: the interrupted item, its playback report and its original text were never removed, and every playback report was kept, interrupted or not. A long voice session grew without limit. The ledger now keeps its records in a BoundedMap of PLAYBACK_ITEMS_KEPT (32) items, oldest forgotten first whatever their phase. Since an item's report, phase and text live in one record, forgetting an item forgets all of it. The separate cap on trailing audio is gone. A late report still truncates within that window; for an item already forgotten it is ignored. Fixes #64
The client reported playback only for items it had played in part, so an item still queued behind the interrupted one, or one that never reached the client, was never truncated: the model's conversation kept a reply the user never heard. - client: PlaybackRing.flush() returns 0 for every item still queued that never started, and the session sends those reports too. - server: an item whose audio.start was dropped on an interruption, or whose audio arrived while no socket was attached, is reported to the session as played for 0 ms. The session's ledger then truncates it to nothing when the item is cut. - spec revision 1.4 clarifies §8.4 to match. It is a SHOULD-level clarification; the playback schema already allowed playedMs 0. - Docs: the transcript guarantee in ARCHITECTURE.md and the interruption pages, English and Chinese. Fixes #63
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description:
Fixes #63, Fixes #64
This PR is the playback half (L2/L3) of the follow-ups to #65 and #66, stacked on #67 for its
BoundedMap. It is based onprovider-errors-usage, and will be retargeted tomainonce #67 merges.Interruption guarantee 3, "the transcript matches what was heard", depended on five structures inside
AgentSessionthat changed at different times and were never cleaned up (#64). It also depended on edge reports that skipped audio the user never heard at all (#63).Behaviours Completed
PlaybackLedger(packages/core/src/playback.ts) replaces#audioItems,#trailingAudio,#played,#interruptedItemsand#untruncatedwith one record per audio item.live,trailing,cut,interrupted), the received and played milliseconds, and the original text.received(),report(),interrupt(),endTurn()anduserTookFloor()only return truncation decisions.AgentSessionapplies them to the model and the transcript, the same split asToolStreams.PLAYBACK_ITEMS_KEPT(32) items, oldest first, whatever their phase. Because an item's report, phase and text are one record, forgetting it forgets all of it.PlaybackRing.flush()returns 0 for items still queued that never started, and the session now sends those reports.audio.startwas dropped on an interruption, or whose audio arrived while no socket was attached, is reported to the session as played for 0 ms.playedMs0, and a server treats audio it could not deliver as not heard. This is a SHOULD-level clarification; theplaybackschema already allows 0.docs/ARCHITECTURE.mddescribes the ledger and the edge contract. The interruption page (English and Chinese) and the client and SDK playback bullets are updated to match.Files Changed
packages/core/src/playback.ts;packages/core/src/session.ts; newpackages/core/test/playback.test.ts;packages/core/test/session.test.ts.packages/client/src/{ring,audio,session}.ts;packages/client/test/{audio-dsp,session}.test.ts.packages/server/src/index.ts,packages/server/test/server.test.ts.spec/protocol.md(§8.4 and Appendix B),spec/README.md,docs/ARCHITECTURE.md,website/src/content/docs/{concepts,zh-cn/concepts}/interruption.md.Brief Explanation
Single responsibility.
AgentSessionorchestrates; the ledger owns the reconciliation as a small explicit state machine:live→trailingat turn end;trailing→cutwhen the user takes the floor;cutorlive→interruptedwhen cut short;The ledger has no model, emitter or clock, so its transitions are unit-tested directly.
session.tsloses about 40 lines.The edge owns the truth about what was heard. The client knows what it played. The server is also an edge: it knows what it never delivered (a dropped
audio.start, no socket attached). Both report through the sameplayback()entry point, so core needs no special case.Cost.
received()runs on the audio hot path with one map lookup and no allocation. Core is 5.72 KB of its 15 KB budget, client 5.28 KB of 20 KB.Testing
npm test(31 files, 520 tests),npm run typecheck,npm run check:budgets,npm run check:spec,npm run test:e2e(7),npm run test:conformance(169),npm run test:workers(2) and the docs build: all green.Unsure About
conversation.item.truncatewithaudio_end_ms: 0has not been checked against the live API. OpenAI accepts it, and Boson documents a 0 ack for text-only sessions.PLAYBACK_ITEMS_KEPTis 32. A turn with more than 32 audio items would forget its oldest live items early. No current adapter produces more than about two per response.AudioSinkthat does not return zeros keeps working; it just misses this correction.