Persist timestamped realtime frames before acknowledging completion (#61473)

## What changed

- Record voice transcripts as `frame` items with a `voice_transcript` channel for eligible threads, while retaining legacy transcript support and the existing app-server live and mixed timeline representation.
- Assign first-observed timestamps that never move backward, retain them through completion, and restore the clock floor from history and compaction checkpoints.
- Flush realtime records before completion notifications, including file and initial directory durability barriers for local rollouts.
- Generate a distinct default session ID for each new call, use the call ID when attaching to an existing call, and derive opening record IDs from session IDs.
- Add protocol types for turn-input acceptance and session-end input receipts, excluding those receipts from live notifications and mixed timelines. Update generated schemas and SDK types.

## Testing

Add coverage for frame serialization and required voice payloads, clock rollback and checkpoint restoration, durability barrier failures and retries, per-call session IDs, and persisted transcript availability immediately after completion.

GitOrigin-RevId: 3f6632b517d563861aee085b723475b8945c391c
This commit is contained in:
Zahan Malkani
2026-11-14 06:14:36 +00:00
committed by copyberry
parent 336b19ab26
commit 5943d7acdf
45 changed files with 1568 additions and 95 deletions
@@ -6141,6 +6141,25 @@
],
"type": "object"
},
"ThreadRealtimeFrameChannels": {
"description": "A frame holds one or more payloads, at most one per channel. They share one ID, observation time, and seal. V1 requires its only channel, `voice_transcript`.",
"properties": {
"voice_transcript": {
"$ref": "#/definitions/ThreadRealtimeVoiceTranscript"
}
},
"required": [
"voice_transcript"
],
"type": "object"
},
"ThreadRealtimeHandoffMode": {
"enum": [
"start",
"steer"
],
"type": "string"
},
"ThreadRealtimeItem": {
"description": "EXPERIMENTAL - a thread-scoped realtime item in the canonical timeline.",
"oneOf": [
@@ -6160,6 +6179,26 @@
"title": "RealtimeSessionStartedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"channels": {
"$ref": "#/definitions/ThreadRealtimeFrameChannels"
},
"type": {
"enum": [
"frame"
],
"title": "FrameThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"channels",
"type"
],
"title": "FrameThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"role": {
@@ -6231,12 +6270,94 @@
],
"title": "RealtimeSessionClosedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"handoff_id": {
"type": "string"
},
"input": {
"type": "string"
},
"input_item_id": {
"type": "string"
},
"mode": {
"$ref": "#/definitions/ThreadRealtimeHandoffMode"
},
"source_event_ids": {
"items": {
"type": "string"
},
"type": "array"
},
"turn_id": {
"type": "string"
},
"type": {
"enum": [
"turnInputAccepted"
],
"title": "TurnInputAcceptedThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"handoff_id",
"input",
"input_item_id",
"mode",
"source_event_ids",
"turn_id",
"type"
],
"title": "TurnInputAcceptedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"input": {
"type": "string"
},
"input_item_id": {
"type": "string"
},
"source_event_ids": {
"items": {
"type": "string"
},
"type": "array"
},
"type": {
"enum": [
"sessionEndInputSaved"
],
"title": "SessionEndInputSavedThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"input",
"input_item_id",
"source_event_ids",
"type"
],
"title": "SessionEndInputSavedThreadRealtimeItem",
"type": "object"
}
],
"properties": {
"id": {
"type": "string"
},
"observedAtMs": {
"description": "Canonical observation time in Unix milliseconds for eligible voice calls.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"realtimeSessionId": {
"type": "string"
}
@@ -6423,6 +6544,21 @@
],
"type": "string"
},
"ThreadRealtimeVoiceTranscript": {
"properties": {
"role": {
"$ref": "#/definitions/ThreadRealtimeTranscriptRole"
},
"text": {
"type": "string"
}
},
"required": [
"role",
"text"
],
"type": "object"
},
"ThreadRevertedNotification": {
"properties": {
"threadId": {
@@ -25579,6 +25579,25 @@
"title": "ThreadRealtimeErrorNotification",
"type": "object"
},
"ThreadRealtimeFrameChannels": {
"description": "A frame holds one or more payloads, at most one per channel. They share one ID, observation time, and seal. V1 requires its only channel, `voice_transcript`.",
"properties": {
"voice_transcript": {
"$ref": "#/definitions/v2/ThreadRealtimeVoiceTranscript"
}
},
"required": [
"voice_transcript"
],
"type": "object"
},
"ThreadRealtimeHandoffMode": {
"enum": [
"start",
"steer"
],
"type": "string"
},
"ThreadRealtimeInitialItem": {
"description": "EXPERIMENTAL - role-bearing text item included when a realtime V3 session starts.",
"properties": {
@@ -25614,6 +25633,26 @@
"title": "RealtimeSessionStartedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"channels": {
"$ref": "#/definitions/v2/ThreadRealtimeFrameChannels"
},
"type": {
"enum": [
"frame"
],
"title": "FrameThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"channels",
"type"
],
"title": "FrameThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"role": {
@@ -25685,12 +25724,94 @@
],
"title": "RealtimeSessionClosedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"handoff_id": {
"type": "string"
},
"input": {
"type": "string"
},
"input_item_id": {
"type": "string"
},
"mode": {
"$ref": "#/definitions/v2/ThreadRealtimeHandoffMode"
},
"source_event_ids": {
"items": {
"type": "string"
},
"type": "array"
},
"turn_id": {
"type": "string"
},
"type": {
"enum": [
"turnInputAccepted"
],
"title": "TurnInputAcceptedThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"handoff_id",
"input",
"input_item_id",
"mode",
"source_event_ids",
"turn_id",
"type"
],
"title": "TurnInputAcceptedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"input": {
"type": "string"
},
"input_item_id": {
"type": "string"
},
"source_event_ids": {
"items": {
"type": "string"
},
"type": "array"
},
"type": {
"enum": [
"sessionEndInputSaved"
],
"title": "SessionEndInputSavedThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"input",
"input_item_id",
"source_event_ids",
"type"
],
"title": "SessionEndInputSavedThreadRealtimeItem",
"type": "object"
}
],
"properties": {
"id": {
"type": "string"
},
"observedAtMs": {
"description": "Canonical observation time in Unix milliseconds for eligible voice calls.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"realtimeSessionId": {
"type": "string"
}
@@ -25958,6 +26079,21 @@
],
"type": "string"
},
"ThreadRealtimeVoiceTranscript": {
"properties": {
"role": {
"$ref": "#/definitions/v2/ThreadRealtimeTranscriptRole"
},
"text": {
"type": "string"
}
},
"required": [
"role",
"text"
],
"type": "object"
},
"ThreadResumeInitialTurnsPageParams": {
"properties": {
"itemsView": {
@@ -23275,6 +23275,25 @@
"title": "ThreadRealtimeErrorNotification",
"type": "object"
},
"ThreadRealtimeFrameChannels": {
"description": "A frame holds one or more payloads, at most one per channel. They share one ID, observation time, and seal. V1 requires its only channel, `voice_transcript`.",
"properties": {
"voice_transcript": {
"$ref": "#/definitions/ThreadRealtimeVoiceTranscript"
}
},
"required": [
"voice_transcript"
],
"type": "object"
},
"ThreadRealtimeHandoffMode": {
"enum": [
"start",
"steer"
],
"type": "string"
},
"ThreadRealtimeInitialItem": {
"description": "EXPERIMENTAL - role-bearing text item included when a realtime V3 session starts.",
"properties": {
@@ -23310,6 +23329,26 @@
"title": "RealtimeSessionStartedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"channels": {
"$ref": "#/definitions/ThreadRealtimeFrameChannels"
},
"type": {
"enum": [
"frame"
],
"title": "FrameThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"channels",
"type"
],
"title": "FrameThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"role": {
@@ -23381,12 +23420,94 @@
],
"title": "RealtimeSessionClosedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"handoff_id": {
"type": "string"
},
"input": {
"type": "string"
},
"input_item_id": {
"type": "string"
},
"mode": {
"$ref": "#/definitions/ThreadRealtimeHandoffMode"
},
"source_event_ids": {
"items": {
"type": "string"
},
"type": "array"
},
"turn_id": {
"type": "string"
},
"type": {
"enum": [
"turnInputAccepted"
],
"title": "TurnInputAcceptedThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"handoff_id",
"input",
"input_item_id",
"mode",
"source_event_ids",
"turn_id",
"type"
],
"title": "TurnInputAcceptedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"input": {
"type": "string"
},
"input_item_id": {
"type": "string"
},
"source_event_ids": {
"items": {
"type": "string"
},
"type": "array"
},
"type": {
"enum": [
"sessionEndInputSaved"
],
"title": "SessionEndInputSavedThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"input",
"input_item_id",
"source_event_ids",
"type"
],
"title": "SessionEndInputSavedThreadRealtimeItem",
"type": "object"
}
],
"properties": {
"id": {
"type": "string"
},
"observedAtMs": {
"description": "Canonical observation time in Unix milliseconds for eligible voice calls.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"realtimeSessionId": {
"type": "string"
}
@@ -23654,6 +23775,21 @@
],
"type": "string"
},
"ThreadRealtimeVoiceTranscript": {
"properties": {
"role": {
"$ref": "#/definitions/ThreadRealtimeTranscriptRole"
},
"text": {
"type": "string"
}
},
"required": [
"role",
"text"
],
"type": "object"
},
"ThreadResumeInitialTurnsPageParams": {
"properties": {
"itemsView": {
@@ -60,6 +60,25 @@
}
]
},
"ThreadRealtimeFrameChannels": {
"description": "A frame holds one or more payloads, at most one per channel. They share one ID, observation time, and seal. V1 requires its only channel, `voice_transcript`.",
"properties": {
"voice_transcript": {
"$ref": "#/definitions/ThreadRealtimeVoiceTranscript"
}
},
"required": [
"voice_transcript"
],
"type": "object"
},
"ThreadRealtimeHandoffMode": {
"enum": [
"start",
"steer"
],
"type": "string"
},
"ThreadRealtimeItem": {
"description": "EXPERIMENTAL - a thread-scoped realtime item in the canonical timeline.",
"oneOf": [
@@ -79,6 +98,26 @@
"title": "RealtimeSessionStartedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"channels": {
"$ref": "#/definitions/ThreadRealtimeFrameChannels"
},
"type": {
"enum": [
"frame"
],
"title": "FrameThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"channels",
"type"
],
"title": "FrameThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"role": {
@@ -150,12 +189,94 @@
],
"title": "RealtimeSessionClosedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"handoff_id": {
"type": "string"
},
"input": {
"type": "string"
},
"input_item_id": {
"type": "string"
},
"mode": {
"$ref": "#/definitions/ThreadRealtimeHandoffMode"
},
"source_event_ids": {
"items": {
"type": "string"
},
"type": "array"
},
"turn_id": {
"type": "string"
},
"type": {
"enum": [
"turnInputAccepted"
],
"title": "TurnInputAcceptedThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"handoff_id",
"input",
"input_item_id",
"mode",
"source_event_ids",
"turn_id",
"type"
],
"title": "TurnInputAcceptedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"input": {
"type": "string"
},
"input_item_id": {
"type": "string"
},
"source_event_ids": {
"items": {
"type": "string"
},
"type": "array"
},
"type": {
"enum": [
"sessionEndInputSaved"
],
"title": "SessionEndInputSavedThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"input",
"input_item_id",
"source_event_ids",
"type"
],
"title": "SessionEndInputSavedThreadRealtimeItem",
"type": "object"
}
],
"properties": {
"id": {
"type": "string"
},
"observedAtMs": {
"description": "Canonical observation time in Unix milliseconds for eligible voice calls.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"realtimeSessionId": {
"type": "string"
}
@@ -179,6 +300,21 @@
"assistant"
],
"type": "string"
},
"ThreadRealtimeVoiceTranscript": {
"properties": {
"role": {
"$ref": "#/definitions/ThreadRealtimeTranscriptRole"
},
"text": {
"type": "string"
}
},
"required": [
"role",
"text"
],
"type": "object"
}
},
"description": "EXPERIMENTAL - a realtime timeline item published after canonical commit.",
@@ -60,6 +60,25 @@
}
]
},
"ThreadRealtimeFrameChannels": {
"description": "A frame holds one or more payloads, at most one per channel. They share one ID, observation time, and seal. V1 requires its only channel, `voice_transcript`.",
"properties": {
"voice_transcript": {
"$ref": "#/definitions/ThreadRealtimeVoiceTranscript"
}
},
"required": [
"voice_transcript"
],
"type": "object"
},
"ThreadRealtimeHandoffMode": {
"enum": [
"start",
"steer"
],
"type": "string"
},
"ThreadRealtimeItem": {
"description": "EXPERIMENTAL - a thread-scoped realtime item in the canonical timeline.",
"oneOf": [
@@ -79,6 +98,26 @@
"title": "RealtimeSessionStartedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"channels": {
"$ref": "#/definitions/ThreadRealtimeFrameChannels"
},
"type": {
"enum": [
"frame"
],
"title": "FrameThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"channels",
"type"
],
"title": "FrameThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"role": {
@@ -150,12 +189,94 @@
],
"title": "RealtimeSessionClosedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"handoff_id": {
"type": "string"
},
"input": {
"type": "string"
},
"input_item_id": {
"type": "string"
},
"mode": {
"$ref": "#/definitions/ThreadRealtimeHandoffMode"
},
"source_event_ids": {
"items": {
"type": "string"
},
"type": "array"
},
"turn_id": {
"type": "string"
},
"type": {
"enum": [
"turnInputAccepted"
],
"title": "TurnInputAcceptedThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"handoff_id",
"input",
"input_item_id",
"mode",
"source_event_ids",
"turn_id",
"type"
],
"title": "TurnInputAcceptedThreadRealtimeItem",
"type": "object"
},
{
"properties": {
"input": {
"type": "string"
},
"input_item_id": {
"type": "string"
},
"source_event_ids": {
"items": {
"type": "string"
},
"type": "array"
},
"type": {
"enum": [
"sessionEndInputSaved"
],
"title": "SessionEndInputSavedThreadRealtimeItemType",
"type": "string"
}
},
"required": [
"input",
"input_item_id",
"source_event_ids",
"type"
],
"title": "SessionEndInputSavedThreadRealtimeItem",
"type": "object"
}
],
"properties": {
"id": {
"type": "string"
},
"observedAtMs": {
"description": "Canonical observation time in Unix milliseconds for eligible voice calls.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"realtimeSessionId": {
"type": "string"
}
@@ -179,6 +300,21 @@
"assistant"
],
"type": "string"
},
"ThreadRealtimeVoiceTranscript": {
"properties": {
"role": {
"$ref": "#/definitions/ThreadRealtimeTranscriptRole"
},
"text": {
"type": "string"
}
},
"required": [
"role",
"text"
],
"type": "object"
}
},
"description": "EXPERIMENTAL - a realtime timeline item started before its content streams.",
@@ -0,0 +1,10 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadRealtimeVoiceTranscript } from "./ThreadRealtimeVoiceTranscript";
/**
* A frame holds one or more payloads, at most one per channel. They share one ID,
* observation time, and seal. V1 requires its only channel, `voice_transcript`.
*/
export type ThreadRealtimeFrameChannels = { voice_transcript: ThreadRealtimeVoiceTranscript, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadRealtimeHandoffMode = "start" | "steer";
@@ -2,10 +2,16 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadRealtimeBemItemPresentation } from "./ThreadRealtimeBemItemPresentation";
import type { ThreadRealtimeFrameChannels } from "./ThreadRealtimeFrameChannels";
import type { ThreadRealtimeHandoffMode } from "./ThreadRealtimeHandoffMode";
import type { ThreadRealtimeSessionOutcome } from "./ThreadRealtimeSessionOutcome";
import type { ThreadRealtimeTranscriptRole } from "./ThreadRealtimeTranscriptRole";
/**
* EXPERIMENTAL - a thread-scoped realtime item in the canonical timeline.
*/
export type ThreadRealtimeItem = { id: string, realtimeSessionId: string, } & ({ "type": "realtimeSessionStarted" } | { "type": "transcriptSegment", role: ThreadRealtimeTranscriptRole, text: string, } | { "type": "bemItemPromoted", turnId: string, itemId: string, presentation: ThreadRealtimeBemItemPresentation, } | { "type": "realtimeSessionClosed", outcome: ThreadRealtimeSessionOutcome, });
export type ThreadRealtimeItem = { id: string, realtimeSessionId: string,
/**
* Canonical observation time in Unix milliseconds for eligible voice calls.
*/
observedAtMs?: number, } & ({ "type": "realtimeSessionStarted" } | { "type": "frame", channels: ThreadRealtimeFrameChannels, } | { "type": "transcriptSegment", role: ThreadRealtimeTranscriptRole, text: string, } | { "type": "bemItemPromoted", turnId: string, itemId: string, presentation: ThreadRealtimeBemItemPresentation, } | { "type": "realtimeSessionClosed", outcome: ThreadRealtimeSessionOutcome, } | { "type": "turnInputAccepted", handoffId: string, turnId: string, mode: ThreadRealtimeHandoffMode, sourceEventIds: Array<string>, inputItemId: string, input: string, } | { "type": "sessionEndInputSaved", sourceEventIds: Array<string>, inputItemId: string, input: string, });
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadRealtimeTranscriptRole } from "./ThreadRealtimeTranscriptRole";
export type ThreadRealtimeVoiceTranscript = { role: ThreadRealtimeTranscriptRole, text: string, };
@@ -585,6 +585,8 @@ export type { ThreadRealtimeAudioChunk } from "./ThreadRealtimeAudioChunk";
export type { ThreadRealtimeBemItemPresentation } from "./ThreadRealtimeBemItemPresentation";
export type { ThreadRealtimeClosedNotification } from "./ThreadRealtimeClosedNotification";
export type { ThreadRealtimeErrorNotification } from "./ThreadRealtimeErrorNotification";
export type { ThreadRealtimeFrameChannels } from "./ThreadRealtimeFrameChannels";
export type { ThreadRealtimeHandoffMode } from "./ThreadRealtimeHandoffMode";
export type { ThreadRealtimeInitialItem } from "./ThreadRealtimeInitialItem";
export type { ThreadRealtimeItem } from "./ThreadRealtimeItem";
export type { ThreadRealtimeItemAddedNotification } from "./ThreadRealtimeItemAddedNotification";
@@ -599,6 +601,7 @@ export type { ThreadRealtimeStartedNotification } from "./ThreadRealtimeStartedN
export type { ThreadRealtimeTranscriptDeltaNotification } from "./ThreadRealtimeTranscriptDeltaNotification";
export type { ThreadRealtimeTranscriptDoneNotification } from "./ThreadRealtimeTranscriptDoneNotification";
export type { ThreadRealtimeTranscriptRole } from "./ThreadRealtimeTranscriptRole";
export type { ThreadRealtimeVoiceTranscript } from "./ThreadRealtimeVoiceTranscript";
export type { ThreadResumeInitialTurnsPageParams } from "./ThreadResumeInitialTurnsPageParams";
export type { ThreadResumeParams } from "./ThreadResumeParams";
export type { ThreadResumeResponse } from "./ThreadResumeResponse";
@@ -8,6 +8,7 @@ use codex_protocol::protocol::RealtimeOutputModality;
use codex_protocol::protocol::RealtimeVoice;
use codex_protocol::protocol::RealtimeVoicesList;
use codex_protocol::realtime::BemItemPresentation as CoreBemItemPresentation;
use codex_protocol::realtime::RealtimeHandoffMode as CoreRealtimeHandoffMode;
use codex_protocol::realtime::RealtimeItem as CoreRealtimeItem;
use codex_protocol::realtime::RealtimeItemContent as CoreRealtimeItemContent;
use codex_protocol::realtime::RealtimeSessionOutcome as CoreRealtimeSessionOutcome;
@@ -24,6 +25,10 @@ use std::collections::BTreeMap;
pub struct ThreadRealtimeItem {
pub id: String,
pub realtime_session_id: String,
/// Canonical observation time in Unix milliseconds for eligible voice calls.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(type = "number", optional)]
pub observed_at_ms: Option<i64>,
#[serde(flatten)]
pub content: ThreadRealtimeItemContent,
}
@@ -38,6 +43,9 @@ pub struct ThreadRealtimeItem {
#[ts(tag = "type", rename_all = "camelCase", export_to = "v2/")]
pub enum ThreadRealtimeItemContent {
RealtimeSessionStarted,
Frame {
channels: ThreadRealtimeFrameChannels,
},
TranscriptSegment {
role: ThreadRealtimeTranscriptRole,
text: String,
@@ -50,6 +58,27 @@ pub enum ThreadRealtimeItemContent {
RealtimeSessionClosed {
outcome: ThreadRealtimeSessionOutcome,
},
TurnInputAccepted {
handoff_id: String,
turn_id: String,
mode: ThreadRealtimeHandoffMode,
source_event_ids: Vec<String>,
input_item_id: String,
input: String,
},
SessionEndInputSaved {
source_event_ids: Vec<String>,
input_item_id: String,
input: String,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum ThreadRealtimeHandoffMode {
Start,
Steer,
}
/// EXPERIMENTAL - how an existing agent item appears in a realtime conversation.
@@ -82,11 +111,52 @@ pub enum ThreadRealtimeSessionOutcome {
Failed,
}
/// A frame holds one or more payloads, at most one per channel. They share one ID,
/// observation time, and seal. V1 requires its only channel, `voice_transcript`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub struct ThreadRealtimeFrameChannels {
pub voice_transcript: ThreadRealtimeVoiceTranscript,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub struct ThreadRealtimeVoiceTranscript {
pub role: ThreadRealtimeTranscriptRole,
pub text: String,
}
impl From<CoreRealtimeTranscriptRole> for ThreadRealtimeTranscriptRole {
fn from(role: CoreRealtimeTranscriptRole) -> Self {
match role {
CoreRealtimeTranscriptRole::User => Self::User,
CoreRealtimeTranscriptRole::Assistant => Self::Assistant,
}
}
}
// The existing live and mixed timeline APIs present speech as transcriptSegment.
impl From<CoreRealtimeItem> for ThreadRealtimeItem {
fn from(item: CoreRealtimeItem) -> Self {
let mut item = Self::from_persisted(item);
if let ThreadRealtimeItemContent::Frame { channels } = item.content {
let transcript = channels.voice_transcript;
item.content = ThreadRealtimeItemContent::TranscriptSegment {
role: transcript.role,
text: transcript.text,
};
}
item
}
}
impl ThreadRealtimeItem {
/// Preserve the canonical shape of a sealed record on the independent read API.
pub fn from_persisted(item: CoreRealtimeItem) -> Self {
let CoreRealtimeItem {
id,
realtime_session_id,
observed_at_ms,
content,
} = item;
let content = match content {
@@ -95,15 +165,21 @@ impl From<CoreRealtimeItem> for ThreadRealtimeItem {
}
CoreRealtimeItemContent::TranscriptSegment { role, text } => {
ThreadRealtimeItemContent::TranscriptSegment {
role: match role {
CoreRealtimeTranscriptRole::User => ThreadRealtimeTranscriptRole::User,
CoreRealtimeTranscriptRole::Assistant => {
ThreadRealtimeTranscriptRole::Assistant
}
},
role: role.into(),
text,
}
}
CoreRealtimeItemContent::Frame { channels } => {
let transcript = channels.voice_transcript;
ThreadRealtimeItemContent::Frame {
channels: ThreadRealtimeFrameChannels {
voice_transcript: ThreadRealtimeVoiceTranscript {
role: transcript.role.into(),
text: transcript.text,
},
},
}
}
CoreRealtimeItemContent::BemItemPromoted {
turn_id,
item_id,
@@ -131,10 +207,38 @@ impl From<CoreRealtimeItem> for ThreadRealtimeItem {
},
}
}
CoreRealtimeItemContent::TurnInputAccepted {
handoff_id,
turn_id,
mode,
source_event_ids,
input_item_id,
input,
} => ThreadRealtimeItemContent::TurnInputAccepted {
handoff_id,
turn_id,
mode: match mode {
CoreRealtimeHandoffMode::Start => ThreadRealtimeHandoffMode::Start,
CoreRealtimeHandoffMode::Steer => ThreadRealtimeHandoffMode::Steer,
},
source_event_ids,
input_item_id,
input,
},
CoreRealtimeItemContent::SessionEndInputSaved {
source_event_ids,
input_item_id,
input,
} => ThreadRealtimeItemContent::SessionEndInputSaved {
source_event_ids,
input_item_id,
input,
},
};
Self {
id,
realtime_session_id,
observed_at_ms,
content,
}
}
@@ -5660,3 +5660,13 @@ fn feedback_paths_preserve_native_wire_spelling_before_host_validation() {
);
}
}
#[test]
fn an_undated_live_voice_item_preserves_its_wire_shape() {
let wire = serde_json::json!({
"id": "speech", "realtimeSessionId": "voice", "type": "transcriptSegment",
"role": "assistant", "text": "hello",
});
let item: ThreadRealtimeItem = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(serde_json::to_value(item).unwrap(), wire);
}
@@ -466,6 +466,12 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
}
EventMsg::RealtimeConversationRealtime(event) => match event.payload {
RealtimeEvent::HistoryItemStarted(item) | RealtimeEvent::HistoryItemCompleted(item)
if matches!(
&item.content,
codex_protocol::realtime::RealtimeItemContent::TurnInputAccepted { .. }
| codex_protocol::realtime::RealtimeItemContent::SessionEndInputSaved { .. }
) => {}
RealtimeEvent::HistoryItemStarted(item) => {
outgoing
.send_server_notification(ServerNotification::ThreadRealtimeItemStarted(
@@ -755,8 +755,11 @@ async fn realtime_conversation_streams_timeline_items() -> Result<()> {
.await?;
assert_eq!(session_started.item, session_completed.item);
assert_eq!(
Uuid::parse_str(&session_started.item.id)?.get_version_num(),
7
session_started.item.id,
format!(
"realtime-session-start:{}",
session_started.item.realtime_session_id
)
);
assert!(matches!(
session_completed.item.content,
@@ -1427,7 +1430,17 @@ async fn realtime_timeline_splits_accepted_steering_and_persists_promoted_artifa
);
for entry in &page.data {
if let ThreadTimelineEntry::Realtime { item, .. } = entry {
assert_eq!(Uuid::parse_str(&item.id)?.get_version_num(), 7);
if matches!(
item.content,
ThreadRealtimeItemContent::RealtimeSessionStarted
) {
assert_eq!(
item.id,
format!("realtime-session-start:{}", item.realtime_session_id)
);
} else {
assert_eq!(Uuid::parse_str(&item.id)?.get_version_num(), 7);
}
}
}
@@ -2400,12 +2413,19 @@ async fn webrtc_v1_start_posts_offer_returns_sdp_and_joins_sideband() -> Result<
// Phase 2: start through app-server and assert the app receives both the started notification
// and the answer SDP.
let started = harness.start_webrtc_realtime("v=offer\r\n").await?;
let call_request = harness.call_capture.single_request();
let session_id = call_request
.headers
.get("x-session-id")
.context("call session ID")?
.to_str()?;
assert_ne!(session_id, harness.thread_id);
assert_eq!(
started,
StartedWebrtcRealtime {
started: ThreadRealtimeStartedNotification {
thread_id: harness.thread_id.clone(),
realtime_session_id: Some(harness.thread_id.clone()),
realtime_session_id: Some(session_id.to_string()),
version: RealtimeConversationVersion::V1,
},
sdp: ThreadRealtimeSdpNotification {
@@ -2531,7 +2551,7 @@ async fn existing_call_attaches_without_reinitializing_the_client_session(
started,
ThreadRealtimeStartedNotification {
thread_id: harness.thread_id.clone(),
realtime_session_id: realtime_session_id.map(str::to_string),
realtime_session_id: Some(realtime_session_id.unwrap_or("rtc_existing").to_string()),
version: expected_version,
}
);
@@ -2663,12 +2683,19 @@ async fn webrtc_v3_start_posts_live_session_and_joins_without_session_update() -
RealtimeConversationVersion::V3,
)
.await?;
let call_request = harness.call_capture.single_request();
let session_id = call_request
.headers
.get("x-session-id")
.context("call session ID")?
.to_str()?;
assert_ne!(session_id, harness.thread_id);
assert_eq!(
started,
StartedWebrtcRealtime {
started: ThreadRealtimeStartedNotification {
thread_id: harness.thread_id.clone(),
realtime_session_id: Some(harness.thread_id.clone()),
realtime_session_id: Some(session_id.to_string()),
version: RealtimeConversationVersion::V3,
},
sdp: ThreadRealtimeSdpNotification {
@@ -4322,12 +4349,19 @@ async fn realtime_starts_on_forked_and_resumed_threads(
}
let started = harness.start_webrtc_realtime("v=offer\r\n").await?;
let call_request = harness.call_capture.single_request();
let session_id = call_request
.headers
.get("x-session-id")
.context("call session ID")?
.to_str()?;
assert_ne!(session_id, harness.thread_id);
assert_eq!(
started,
StartedWebrtcRealtime {
started: ThreadRealtimeStartedNotification {
thread_id: harness.thread_id.clone(),
realtime_session_id: Some(harness.thread_id.clone()),
realtime_session_id: Some(session_id.to_string()),
version: RealtimeConversationVersion::V1,
},
sdp: ThreadRealtimeSdpNotification {
@@ -95,6 +95,7 @@ async fn timeline_pages_mix_items_and_resolve_the_opening_realtime_session() ->
RolloutItem::RealtimeItem(RealtimeItem {
id: "voice-1:started".to_string(),
realtime_session_id: "voice-1".to_string(),
observed_at_ms: Some(1_000),
content: RealtimeItemContent::RealtimeSessionStarted,
}),
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
@@ -124,6 +125,7 @@ async fn timeline_pages_mix_items_and_resolve_the_opening_realtime_session() ->
RolloutItem::RealtimeItem(RealtimeItem {
id: "voice-1:transcript-1".to_string(),
realtime_session_id: "voice-1".to_string(),
observed_at_ms: None,
content: RealtimeItemContent::TranscriptSegment {
role: RealtimeTranscriptRole::Assistant,
text: "Checking staging".to_string(),
+3
View File
@@ -2719,6 +2719,7 @@ async fn spawn_agent_fork_sanitizes_inherited_compaction_metadata() {
next_item_ordinal: None,
rejected_audio_file_ids: Default::default(),
dynamic_tools: None,
observation_clock_floor_ms: None,
multi_agent_version: Some(MultiAgentVersion::V2),
last_started_turn_id: Some("parent-turn".to_string()),
turn_attribution: Some(codex_history::TurnAttribution {
@@ -3885,6 +3886,7 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc
next_item_ordinal: None,
rejected_audio_file_ids: Default::default(),
dynamic_tools: None,
observation_clock_floor_ms: None,
multi_agent_version: Some(MultiAgentVersion::V2),
last_started_turn_id: None,
turn_attribution: None,
@@ -4022,6 +4024,7 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc
next_item_ordinal: None,
rejected_audio_file_ids: Default::default(),
dynamic_tools: None,
observation_clock_floor_ms: None,
multi_agent_version: Some(MultiAgentVersion::V2),
last_started_turn_id: None,
turn_attribution: None,
+1
View File
@@ -233,6 +233,7 @@ pub use state_db_bridge::init_state_db;
mod thread_rollout_truncation;
pub use thread_rollout_truncation::truncate_rollout_after_turn_id;
pub use thread_rollout_truncation::truncate_rollout_before_turn_id;
mod observation_clock;
mod tools;
pub(crate) mod turn_diff_tracker;
mod turn_metadata;
+48
View File
@@ -0,0 +1,48 @@
//! Assigns first-observed timestamps to realtime records for a single thread owner.
use codex_history::RolloutItem;
#[derive(Debug, Default)]
pub(crate) struct ObservationClock {
last_assigned_ms: Option<i64>,
}
impl ObservationClock {
pub(crate) fn restore(&mut self, items: &[RolloutItem]) {
let floor = items
.iter()
.filter_map(|item| match item {
RolloutItem::RealtimeItem(item) => item.observed_at_ms,
RolloutItem::Compacted(compacted) => compacted
.resume_metadata
.as_ref()
.and_then(|metadata| metadata.observation_clock_floor_ms),
_ => None,
})
.max();
if let Some(floor) = floor {
self.observe(floor);
}
}
pub(crate) fn now_ms(&mut self) -> i64 {
self.observe(crate::turn_timing::now_unix_timestamp_ms())
}
pub(crate) fn floor_ms(&self) -> Option<i64> {
self.last_assigned_ms
}
pub(crate) fn observe(&mut self, wall_clock_ms: i64) -> i64 {
let time = self
.last_assigned_ms
.unwrap_or(wall_clock_ms)
.max(wall_clock_ms);
self.last_assigned_ms = Some(time);
time
}
}
#[cfg(test)]
#[path = "observation_clock_tests.rs"]
mod tests;
@@ -0,0 +1,73 @@
#![cfg(test)]
//! Checks clock rollback and restoration from durable history checkpoints.
use super::ObservationClock;
use codex_history::RolloutItem;
use codex_protocol::realtime::RealtimeItem;
use codex_protocol::realtime::RealtimeItemContent;
use pretty_assertions::assert_eq;
#[test]
fn observation_time_survives_clock_rollback_and_owner_restart() {
let mut clock = ObservationClock::default();
assert_eq!(
[
clock.observe(/*wall_clock_ms*/ 100),
clock.observe(/*wall_clock_ms*/ 90),
clock.observe(/*wall_clock_ms*/ 110)
],
[100, 100, 110]
);
let mut clock = ObservationClock::default();
clock.restore(&[
RolloutItem::RealtimeItem(RealtimeItem {
id: "sealed".into(),
realtime_session_id: "voice".into(),
observed_at_ms: Some(110),
content: RealtimeItemContent::RealtimeSessionStarted,
}),
RolloutItem::RealtimeItem(RealtimeItem {
id: "legacy".into(),
realtime_session_id: "legacy-voice".into(),
observed_at_ms: None,
content: RealtimeItemContent::RealtimeSessionStarted,
}),
]);
assert_eq!(
[
clock.observe(/*wall_clock_ms*/ 80),
clock.observe(/*wall_clock_ms*/ 120)
],
[110, 120]
);
}
#[test]
fn compaction_retains_the_floor_when_older_events_are_omitted() {
let compacted = serde_json::from_value::<RolloutItem>(serde_json::json!({
"type": "compacted",
"payload": {
"message": "checkpoint",
"replacement_history": [],
"window_number": 1,
"resume_metadata": {
"observation_clock_floor_ms": 150,
"multi_agent_version": null,
"last_started_turn_id": null,
"previous_turn_settings": null
}
}
}))
.expect("read persisted checkpoint");
let mut clock = ObservationClock::default();
clock.restore(&[compacted]);
assert_eq!(
[
clock.observe(/*wall_clock_ms*/ 100),
clock.observe(/*wall_clock_ms*/ 200)
],
[150, 200]
);
}
+14 -10
View File
@@ -1575,11 +1575,8 @@ async fn prepare_realtime_start(
ConfiguredRealtimeVoice::Use
}
};
let mut session_config =
let session_config =
build_realtime_session_config(sess, &params, version, configured_voice).await?;
if matches!(&transport, ConversationStartTransport::ExistingCall { .. }) {
session_config.session_id = params.realtime_session_id.clone();
}
let requested_realtime_session_id = session_config.session_id.clone();
let event_parser = session_config.event_parser;
let originator = sess.originator().await;
@@ -1804,12 +1801,16 @@ pub(crate) async fn build_realtime_session_config(
initial_items: params.initial_items.clone(),
delegation_ack_filler: params.delegation_ack_filler,
model,
session_id: Some(
params
.realtime_session_id
.clone()
.unwrap_or_else(|| sess.thread_id.to_string()),
),
session_id: Some(params.realtime_session_id.clone().unwrap_or_else(|| {
match &params.transport {
Some(ConversationStartTransport::ExistingCall { call_id, .. }) => call_id.clone(),
Some(
ConversationStartTransport::Webrtc { .. }
| ConversationStartTransport::Websocket,
)
| None => uuid::Uuid::now_v7().to_string(),
}
})),
event_parser,
session_mode,
output_modality: params.output_modality,
@@ -1893,6 +1894,9 @@ async fn handle_start_inner(
session_config,
transport,
} = prepared_start;
sess.initialize_realtime_history()
.or_cancel(&attachment_control.token)
.await??;
info!("starting realtime conversation");
let (sdp, existing_call_id) = match transport {
ConversationStartTransport::Websocket => (None, None),
+52 -9
View File
@@ -4,16 +4,19 @@
mod presentation;
use crate::observation_clock::ObservationClock;
use codex_protocol::items::TurnItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RealtimeEvent;
use codex_protocol::protocol::RealtimeTranscriptDelta;
use codex_protocol::protocol::RealtimeTranscriptDone;
use codex_protocol::realtime::BemItemPresentation;
use codex_protocol::realtime::RealtimeFrameChannels;
use codex_protocol::realtime::RealtimeItem;
use codex_protocol::realtime::RealtimeItemContent;
use codex_protocol::realtime::RealtimeSessionOutcome;
use codex_protocol::realtime::RealtimeTranscriptRole;
use codex_protocol::realtime::RealtimeVoiceTranscript;
use codex_protocol::user_input::UserInput;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -26,6 +29,7 @@ struct ActiveSegment {
id: String,
role: RealtimeTranscriptRole,
text: String,
observed_at_ms: Option<i64>,
}
#[derive(Debug, Default)]
@@ -71,6 +75,7 @@ pub(crate) struct RealtimeTranscriptStream {
#[derive(Debug, Default)]
pub(crate) struct RealtimeEventEffects {
pub(crate) seal_frames: bool,
pub(crate) order: RealtimeEventOrder,
pub(crate) items: Vec<RealtimeItem>,
pub(crate) transcript_stream: Option<RealtimeTranscriptStream>,
@@ -92,6 +97,9 @@ enum Continuation {
/// Retains only live session state; durable history is served by the rollout index.
#[derive(Default)]
pub(crate) struct RealtimeHistoryState {
pub(crate) observation_clock: ObservationClock,
/// Resolved once at the first call. Unresolved and unenrolled threads write legacy history.
pub(crate) frame_enrollment: Option<bool>,
active_session_id: Option<String>,
active_segments: ActiveTranscriptSegments,
streaming_agent_message: Option<StreamingAgentMessage>,
@@ -103,6 +111,10 @@ pub(crate) struct RealtimeHistoryState {
}
impl RealtimeHistoryState {
pub(crate) fn frames_enabled(&self) -> bool {
self.frame_enrollment == Some(true)
}
fn should_seal_user_input(&self, input: &[UserInput]) -> bool {
self.active_session_id.is_some()
&& [&self.active_segments.user, &self.active_segments.assistant]
@@ -188,8 +200,16 @@ impl RealtimeHistoryState {
self.active_session_id = Some(session_id.clone());
self.failed = false;
items.push(RealtimeItem {
id: Uuid::now_v7().to_string(),
// The call ID lets a reader seek directly to its opening position.
id: if self.frames_enabled() {
format!("realtime-session-start:{session_id}")
} else {
Uuid::now_v7().to_string()
},
realtime_session_id: session_id.clone(),
observed_at_ms: self
.frames_enabled()
.then(|| self.observation_clock.now_ms()),
content: RealtimeItemContent::RealtimeSessionStarted,
});
}
@@ -284,6 +304,9 @@ impl RealtimeHistoryState {
items.push(RealtimeItem {
id: Uuid::now_v7().to_string(),
realtime_session_id: session_id,
observed_at_ms: self
.frames_enabled()
.then(|| self.observation_clock.now_ms()),
content: RealtimeItemContent::RealtimeSessionClosed {
outcome: if self.failed {
RealtimeSessionOutcome::Failed
@@ -298,6 +321,7 @@ impl RealtimeHistoryState {
_ => {}
}
RealtimeEventEffects {
seal_frames: self.frames_enabled(),
order,
items,
transcript_stream,
@@ -329,6 +353,9 @@ impl RealtimeHistoryState {
items.push(RealtimeItem {
id: Uuid::now_v7().to_string(),
realtime_session_id,
observed_at_ms: self
.frames_enabled()
.then(|| self.observation_clock.now_ms()),
content: RealtimeItemContent::BemItemPromoted {
turn_id: turn_id.to_string(),
item_id: item_id.to_string(),
@@ -344,6 +371,7 @@ impl RealtimeHistoryState {
) -> Option<RealtimeTranscriptStream> {
let session_id = self.active_session_id.clone()?;
self.active_segments.first_active_role.get_or_insert(role);
let frames_enabled = self.frames_enabled();
let segment = self
.active_segments
.slot_mut(role)
@@ -352,14 +380,18 @@ impl RealtimeHistoryState {
id: Uuid::now_v7().to_string(),
role,
text: String::new(),
observed_at_ms: None,
});
let observed_at_ms = frames_enabled.then(|| {
*segment
.observed_at_ms
.get_or_insert_with(|| self.observation_clock.now_ms())
});
let started_item = segment.text.is_empty().then(|| RealtimeItem {
id: segment.id.clone(),
realtime_session_id: segment.session_id.clone(),
content: RealtimeItemContent::TranscriptSegment {
role: segment.role,
text: String::new(),
},
observed_at_ms,
content: speech(frames_enabled, segment.role, String::new()),
});
segment.text.push_str(&delta.delta);
Some(RealtimeTranscriptStream {
@@ -425,10 +457,8 @@ impl RealtimeHistoryState {
items.push(RealtimeItem {
id: segment.id,
realtime_session_id: segment.session_id.clone(),
content: RealtimeItemContent::TranscriptSegment {
role: segment.role,
text: segment.text,
},
observed_at_ms: segment.observed_at_ms,
content: speech(self.frames_enabled(), segment.role, segment.text),
});
}
if matches!(continuation, Continuation::Continue) {
@@ -440,11 +470,24 @@ impl RealtimeHistoryState {
id: Uuid::now_v7().to_string(),
role: segment.role,
text: String::new(),
observed_at_ms: None,
});
}
}
}
fn speech(frames_enabled: bool, role: RealtimeTranscriptRole, text: String) -> RealtimeItemContent {
if frames_enabled {
RealtimeItemContent::Frame {
channels: RealtimeFrameChannels {
voice_transcript: RealtimeVoiceTranscript { role, text },
},
}
} else {
RealtimeItemContent::TranscriptSegment { role, text }
}
}
#[cfg(test)]
#[path = "realtime_history_tests.rs"]
mod tests;
@@ -345,6 +345,7 @@ fn streams_stable_segment_items_and_ignores_upstream_transcript_revisions() {
done.items,
vec![RealtimeItem {
id: started.id,
observed_at_ms: started.observed_at_ms,
realtime_session_id: "voice-1".to_string(),
content: RealtimeItemContent::TranscriptSegment {
role: RealtimeTranscriptRole::Assistant,
@@ -372,6 +373,7 @@ fn emits_the_complete_item_lifecycle_when_only_a_final_transcript_arrives() {
effects.items,
vec![RealtimeItem {
id: started.id,
observed_at_ms: started.observed_at_ms,
realtime_session_id: "voice-1".to_string(),
content: RealtimeItemContent::TranscriptSegment {
role: RealtimeTranscriptRole::Assistant,
@@ -566,6 +568,7 @@ fn promotes_successful_dynamic_tools_and_splits_active_transcripts() {
items[0],
RealtimeItem {
id: first.item_id.clone(),
observed_at_ms: first.started_item.as_ref().unwrap().observed_at_ms,
realtime_session_id: "voice-1".to_string(),
content: RealtimeItemContent::TranscriptSegment {
role: RealtimeTranscriptRole::Assistant,
+18 -3
View File
@@ -607,6 +607,7 @@ impl Session {
thread_id: self.thread_id(),
tools: state.persisted_dynamic_tools.to_vec(),
})),
observation_clock_floor_ms: None,
multi_agent_version: self.multi_agent_version(),
last_started_turn_id: state.last_started_turn_id.clone(),
turn_attribution: state.turn_attribution.clone(),
@@ -1970,6 +1971,13 @@ impl Session {
turn_context: &Arc<TurnContext>,
rollout_items: &[RolloutItem],
) -> Option<PreviousTurnSettings> {
if let Some(history) = &self.realtime_history {
history
.lock()
.await
.observation_clock
.restore(rollout_items);
}
let rollout_reconstruction::RolloutReconstruction {
mut history,
rejected_audio_file_ids,
@@ -2953,8 +2961,8 @@ impl Session {
}
pub(crate) async fn send_event_raw(&self, event: Event) {
self.send_event_raw_with_persistence(event, /*persist*/ true)
.await;
// Keep event persistence out of callers' stack frames.
Box::pin(self.send_event_raw_with_persistence(event, /*persist*/ true)).await;
}
/// Delivers an event without creating a local rollout for a thread that has not materialized.
@@ -4879,6 +4887,10 @@ impl Session {
// Wait for accepted updates to finish persisting, then keep later updates from
// overtaking the current settings snapshot while its checkpoint is written.
let _settings_guard = thread_settings::acquire_persistence_lock(self).await;
let observation_clock_floor_ms = match &self.realtime_history {
Some(history) => history.lock().await.observation_clock.floor_ms(),
None => None,
};
let (compacted_item, world_state_baseline, started_item) = {
let mut state = self.state.lock().await;
let complete_replacement =
@@ -4958,7 +4970,10 @@ impl Session {
state
.history
.set_world_state_baseline(world_state_baseline.clone());
let resume_metadata = self.compaction_resume_metadata(&state);
let resume_metadata = CompactionResumeMetadata {
observation_clock_floor_ms,
..self.compaction_resume_metadata(&state)
};
let compacted_item = CompactedItem {
message: metadata.message,
replacement_history: Some(replacement_history),
+37 -12
View File
@@ -8,6 +8,25 @@ use codex_protocol::protocol::RealtimeEvent;
use codex_protocol::realtime::RealtimeItemContent;
impl Session {
/// Start and attach operations run sequentially in the submission loop.
/// Cache enrollment after a successful lookup so a failed start can retry.
pub(crate) async fn initialize_realtime_history(&self) -> std::io::Result<bool> {
let Some(history) = &self.realtime_history else {
return Ok(false);
};
if let Some(enabled) = history.lock().await.frame_enrollment {
return Ok(enabled);
}
let enabled = self
.services
.thread_store
.realtime_frames_enabled(self.thread_id())
.await
.map_err(std::io::Error::other)?;
history.lock().await.frame_enrollment = Some(enabled);
Ok(enabled)
}
pub(super) async fn send_realtime_history_effects(
&self,
submission_id: &str,
@@ -33,18 +52,24 @@ impl Session {
if effects.items.is_empty() {
return Ok(());
}
self.live_thread_for_persistence("append realtime history")?
.append_items(
&effects
.items
.iter()
.cloned()
.map(RolloutItem::RealtimeItem)
.collect::<Vec<_>>(),
)
.await?;
for item in effects.items {
if !matches!(&item.content, RealtimeItemContent::TranscriptSegment { .. }) {
let live_thread = self.live_thread_for_persistence("append realtime history")?;
let items = effects.items;
let records = items
.iter()
.cloned()
.map(RolloutItem::RealtimeItem)
.collect::<Vec<_>>();
live_thread.append_items(&records).await?;
// A completed notification acknowledges durable history, including on hosts
// whose append operation only admits work to an asynchronous writer.
if effects.seal_frames {
live_thread.flush().await?;
}
for item in items {
if !matches!(
&item.content,
RealtimeItemContent::Frame { .. } | RealtimeItemContent::TranscriptSegment { .. }
) {
self.deliver_event_raw(event(RealtimeEvent::HistoryItemStarted(item.clone())))
.await;
}
@@ -1645,6 +1645,7 @@ async fn bounded_replay_matches_full_replay_after_empty_turn_compactions(
next_item_ordinal: None,
rejected_audio_file_ids: Default::default(),
dynamic_tools: None,
observation_clock_floor_ms: None,
multi_agent_version: None,
last_started_turn_id: Some(format!("wake-{window_number}")),
turn_attribution: None,
+4 -3
View File
@@ -1283,7 +1283,8 @@ impl Session {
error!("failed to initialize thread persistence: {e:#}");
e
})?;
let session_result: anyhow::Result<Arc<Self>> = async {
// Keep the large initialization future out of callers' stack frames.
let session_result: anyhow::Result<Arc<Self>> = Box::pin(async {
if let InitialHistory::Resumed(resumed) = &mut initial_history
&& let Some(history) = resume_context
{
@@ -1994,7 +1995,7 @@ impl Session {
realtime_history: (session_configuration.history_mode
== ThreadHistoryMode::Paginated
&& services.live_thread.is_some())
.then(|| Mutex::new(Default::default())),
.then(|| Mutex::new(crate::realtime_history::RealtimeHistoryState::default())),
active_turn: Mutex::new(None),
async_hook_results,
input_queue: InputQueue::with_controller(
@@ -2143,7 +2144,7 @@ impl Session {
state.queue_pending_session_start_source(session_start_source);
}
Ok(sess)
}
})
.await;
match session_result {
Ok(sess) => {
+1
View File
@@ -6177,6 +6177,7 @@ async fn compaction_persists_resume_metadata_and_companion_records() {
thread_id: session.thread_id(),
tools: Vec::new(),
})),
observation_clock_floor_ms: None,
multi_agent_version: Some(MultiAgentVersion::V2),
last_started_turn_id: Some(turn_context.sub_id.clone()),
turn_attribution: Some(codex_history::TurnAttribution {
+3 -2
View File
@@ -149,14 +149,15 @@ impl RegularTask {
turn.id = %ctx.sub_id,
);
loop {
let last_agent_message = run_turn(
// Leave stack room for nested event persistence while the turn is polled.
let last_agent_message = Box::pin(run_turn(
Arc::clone(&sess),
Arc::clone(&ctx),
next_input,
&mut mcp_startup_requirements,
prewarmed_client_session.take(),
cancellation_token.child_token(),
)
))
.instrument(run_turn_span.clone())
.await?;
// Terminal errors are already reported. Let task completion preserve pending
@@ -476,15 +476,22 @@ async fn conversation_records_history_without_an_event_observer(
) -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let mut events = vec![
json!({ "type": "session.updated", "session": { "id": "voice-1" } }),
json!({ "type": "response.output_text.delta", "delta": "assistant first" }),
json!({ "type": "conversation.item.input_audio_transcription.delta", "delta": "user second" }),
];
if outcome == ThreadRealtimeSessionOutcome::Failed {
events.push(json!({ "type": "error", "error": { "message": "fixture failure" } }));
}
let realtime_server = start_websocket_server(vec![vec![events.clone()], vec![events]]).await;
let session_ids = ["voice-1", "voice-2"];
let calls = session_ids
.iter()
.map(|session_id| {
let mut events = vec![
json!({ "type": "session.updated", "session": { "id": session_id } }),
json!({ "type": "response.output_text.delta", "delta": "assistant first" }),
json!({ "type": "conversation.item.input_audio_transcription.delta", "delta": "user second" }),
];
if outcome == ThreadRealtimeSessionOutcome::Failed {
events.push(json!({ "type": "error", "error": { "message": "fixture failure" } }));
}
vec![events]
})
.collect();
let realtime_server = start_websocket_server(calls).await;
let mut builder = test_codex()
.with_history_mode(ThreadHistoryMode::Paginated)
.with_config({
@@ -497,7 +504,8 @@ async fn conversation_records_history_without_an_event_observer(
let test = builder.build_with_auto_env(&api_server).await?;
test.codex.ensure_rollout_materialized().await;
let mut expected = Vec::new();
for session_count in 1..=2 {
for previous_calls in 0..session_ids.len() {
let first_item = expected.len();
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
@@ -516,7 +524,7 @@ async fn conversation_records_history_without_an_event_observer(
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("fixture".to_string())),
realtime_session_id: Some("voice-1".to_string()),
realtime_session_id: None,
voice_session_id: None,
transport: None,
version: None,
@@ -553,7 +561,7 @@ async fn conversation_records_history_without_an_event_observer(
)
})
.count()
== session_count
== previous_calls + 1
{
break Ok::<_, anyhow::Error>(items);
}
@@ -586,10 +594,17 @@ async fn conversation_records_history_without_an_event_observer(
.map(|item| item.id.as_str())
.collect::<std::collections::HashSet<_>>();
assert_eq!(ids.len(), items.len());
let session_id = &items[first_item].realtime_session_id;
Uuid::parse_str(session_id)?;
assert_ne!(session_id, &test.session_configured.thread_id.to_string());
assert_eq!(
items[first_item].id,
format!("realtime-session-start:{session_id}")
);
assert!(
items
items[first_item..]
.iter()
.all(|item| item.realtime_session_id == "voice-1")
.all(|item| &item.realtime_session_id == session_id)
);
}
test.codex.submit(Op::Shutdown).await?;
@@ -739,22 +754,20 @@ async fn conversation_websocket_transports_send_codex_headers_without_creating_a
})
.await
.expect("realtime websocket connection failed");
let (expected_uri, expected_realtime_session_id) = match transport {
ConversationStartTransport::Websocket => (
"/v1/live?model=gpt-live-1.1-codex",
Some(conversation.thread_id.to_string()),
),
ConversationStartTransport::ExistingCall { .. } => ("/v1/live/rtc_existing", None),
assert_eq!(started.version, RealtimeConversationVersion::V3);
let session_id = started.realtime_session_id.context("started session ID")?;
let expected_uri = match transport {
ConversationStartTransport::Websocket => {
Uuid::parse_str(&session_id)?;
assert_ne!(session_id, conversation.thread_id.to_string());
"/v1/live?model=gpt-live-1.1-codex"
}
ConversationStartTransport::ExistingCall { call_id, .. } => {
assert_eq!(session_id, call_id);
"/v1/live/rtc_existing"
}
ConversationStartTransport::Webrtc { .. } => unreachable!("websocket transports only"),
};
assert_eq!(
(started.version, started.realtime_session_id),
(
RealtimeConversationVersion::V3,
expected_realtime_session_id.clone()
)
);
let handshake = realtime_server.single_handshake();
assert_eq!(handshake.uri(), expected_uri);
assert_eq!(
@@ -762,8 +775,8 @@ async fn conversation_websocket_transports_send_codex_headers_without_creating_a
Some("Bearer dummy")
);
assert_eq!(
handshake.header("x-session-id"),
expected_realtime_session_id
handshake.header("x-session-id").as_deref(),
Some(session_id.as_str())
);
assert_eq!(
(
@@ -3981,17 +3994,27 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime(ephemeral: bool) ->
.await;
assert_eq!(session_updated, "sess_user_text");
let mut transcript_started = None;
let mut output_seen = false;
wait_for_event(&test.codex, |event| {
if let EventMsg::RealtimeConversationRealtime(event) = event {
match event.payload {
RealtimeEvent::HistoryItemStarted(_)
| RealtimeEvent::HistoryTranscriptDelta { .. }
match &event.payload {
RealtimeEvent::HistoryItemStarted(item) => {
assert!(!ephemeral);
if matches!(
item.content,
codex_protocol::realtime::RealtimeItemContent::Frame { .. }
) {
transcript_started = Some(item.clone());
}
}
RealtimeEvent::HistoryTranscriptDelta { .. }
| RealtimeEvent::HistoryItemCompleted(_) => assert!(!ephemeral),
RealtimeEvent::OutputTranscriptDelta(_) => return true,
RealtimeEvent::OutputTranscriptDelta(_) => output_seen = true,
_ => {}
}
}
false
output_seen && (ephemeral || transcript_started.is_some())
})
.await;
@@ -4003,6 +4026,44 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime(ephemeral: bool) ->
}]))
.await?;
if let Some(started) = transcript_started {
assert!(started.observed_at_ms.is_some());
let sealed = wait_for_event_match(&test.codex, |event| match event {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::HistoryItemCompleted(item),
}) if item.id == started.id => Some(item.clone()),
_ => None,
})
.await;
assert_eq!(sealed.observed_at_ms, started.observed_at_ms);
assert!(matches!(
&sealed.content,
codex_protocol::realtime::RealtimeItemContent::Frame { channels }
if channels.voice_transcript.text == "spoken before typed input"
));
// Completion is the persistence barrier; no additional flush is needed to read it.
let timeline = test
.thread_store
.list_timeline(ListTimelineParams {
thread_id: test.session_configured.thread_id,
cursor: None,
page_size: 100,
})
.await?;
let persisted = timeline
.items
.iter()
.find_map(|entry| match entry {
ThreadTimelineEntry::Realtime { item, .. } if item.id == sealed.id => Some(item),
_ => None,
})
.expect("completed realtime item is already readable");
assert_eq!(
persisted,
&codex_app_server_protocol::ThreadRealtimeItem::from(sealed)
);
}
let turn_complete = wait_for_event_match(&test.codex, |event| match event {
EventMsg::TurnComplete(turn_complete) => Some(turn_complete.clone()),
_ => None,
@@ -4014,9 +4075,6 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime(ephemeral: bool) ->
assert!(model_user_texts.iter().any(|text| text == user_text));
if !ephemeral {
test.thread_store
.flush_thread(test.session_configured.thread_id)
.await?;
let timeline = test
.thread_store
.list_timeline(ListTimelineParams {
@@ -27,6 +27,9 @@ pub struct CompactionResumeMetadata {
/// Catalog owned by the producing thread; absent in older checkpoints.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dynamic_tools: Option<Box<DynamicToolsSnapshot>>,
/// Thread clock floor retained when older history is omitted from resume context.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observation_clock_floor_ms: Option<i64>,
/// Selected runtime, including when the companion turn context is absent.
pub multi_agent_version: Option<MultiAgentVersion>,
/// Turn identity used to admit continuations after cold resume.
+2
View File
@@ -699,6 +699,7 @@ fn compacted_resume_metadata_presence_round_trips_empty_values() -> Result<()> {
next_item_ordinal: None,
rejected_audio_file_ids: Default::default(),
dynamic_tools: None,
observation_clock_floor_ms: None,
multi_agent_version: None,
last_started_turn_id: None,
turn_attribution: None,
@@ -813,6 +814,7 @@ fn compacted_metadata_remains_compatible_with_legacy_response_item_readers() ->
next_item_ordinal: None,
rejected_audio_file_ids: Default::default(),
dynamic_tools: None,
observation_clock_floor_ms: None,
multi_agent_version: Some(MultiAgentVersion::V2),
last_started_turn_id: Some("turn-1".to_string()),
turn_attribution: None,
+51
View File
@@ -8,16 +8,38 @@ use ts_rs::TS;
pub struct RealtimeItem {
pub id: String,
pub realtime_session_id: String,
/// Owner-assigned Unix milliseconds at first observation, retained when sealed.
/// Absent for history recorded before canonical observation times were introduced.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(type = "number | null", optional)]
pub observed_at_ms: Option<i64>,
#[serde(flatten)]
pub content: RealtimeItemContent,
}
/// A frame holds one or more payloads, at most one per channel. They share one ID,
/// observation time, and seal. V1 requires its only channel, `voice_transcript`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)]
pub struct RealtimeFrameChannels {
pub voice_transcript: RealtimeVoiceTranscript,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)]
pub struct RealtimeVoiceTranscript {
pub role: RealtimeTranscriptRole,
pub text: String,
}
/// The minimum facts needed to interleave realtime speech and agent work.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type", rename_all = "snake_case")]
pub enum RealtimeItemContent {
RealtimeSessionStarted,
Frame {
channels: RealtimeFrameChannels,
},
/// Original rollout spelling, retained for older histories and unenrolled writers.
TranscriptSegment {
role: RealtimeTranscriptRole,
text: String,
@@ -30,6 +52,31 @@ pub enum RealtimeItemContent {
RealtimeSessionClosed {
outcome: RealtimeSessionOutcome,
},
/// Durable acceptance of the exact bounded input used by an agent turn.
/// The enclosing item ID is the acceptance identity; source IDs never use
/// provisional rollout ordinals.
TurnInputAccepted {
handoff_id: String,
turn_id: String,
mode: RealtimeHandoffMode,
source_event_ids: Vec<String>,
input_item_id: String,
input: String,
},
/// End-of-call input recorded in model history without starting or steering a turn.
SessionEndInputSaved {
source_event_ids: Vec<String>,
input_item_id: String,
input: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
pub enum RealtimeHandoffMode {
Start,
Steer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)]
@@ -57,3 +104,7 @@ pub enum BemItemPresentation {
InlineMarkdown,
InlineVisualization { index: u32 },
}
#[cfg(test)]
#[path = "realtime_tests.rs"]
mod tests;
+22
View File
@@ -0,0 +1,22 @@
#![cfg(test)]
//! Validates the sparse channel envelope written by new voice calls.
use super::RealtimeItem;
use pretty_assertions::assert_eq;
#[test]
fn frame_roundtrips_and_requires_a_voice_payload() {
let frame = serde_json::json!({
"id": "speech",
"realtime_session_id": "voice",
"observed_at_ms": 1050,
"type": "frame",
"channels": { "voice_transcript": { "role": "assistant", "text": "hello" } },
});
let item = serde_json::from_value::<RealtimeItem>(frame.clone()).unwrap();
assert_eq!(serde_json::to_value(item).unwrap(), frame);
let mut empty = frame;
empty["channels"] = serde_json::json!({});
assert!(serde_json::from_value::<RealtimeItem>(empty).is_err());
}
@@ -270,6 +270,9 @@ fn rollout_item_type(item: &RolloutItem) -> String {
codex_protocol::realtime::RealtimeItemContent::RealtimeSessionStarted => {
"realtime.session_started".to_string()
}
codex_protocol::realtime::RealtimeItemContent::Frame { .. } => {
"realtime.frame".to_string()
}
codex_protocol::realtime::RealtimeItemContent::TranscriptSegment { .. } => {
"realtime.transcript_segment".to_string()
}
@@ -279,6 +282,12 @@ fn rollout_item_type(item: &RolloutItem) -> String {
codex_protocol::realtime::RealtimeItemContent::RealtimeSessionClosed { .. } => {
"realtime.session_closed".to_string()
}
codex_protocol::realtime::RealtimeItemContent::TurnInputAccepted { .. } => {
"realtime.turn_input_accepted".to_string()
}
codex_protocol::realtime::RealtimeItemContent::SessionEndInputSaved { .. } => {
"realtime.session_end_input_saved".to_string()
}
},
RolloutItem::EventMsg(EventMsg::ItemCompleted(event)) => {
format!("event.item_completed.{}", turn_item_type(&event.item))
+50 -3
View File
@@ -1021,6 +1021,9 @@ impl RolloutRecorder {
writer: None,
deferred_creation: true,
pending_items: Vec::new(),
realtime_sync_pending: false,
#[cfg(unix)]
directories_to_sync: Vec::new(),
meta: Some(session_meta),
cwd: cwd.clone(),
rollout_path: path,
@@ -1035,6 +1038,9 @@ impl RolloutRecorder {
writer: Some(JsonlWriter { file }),
deferred_creation: false,
pending_items: Vec::new(),
realtime_sync_pending: false,
#[cfg(unix)]
directories_to_sync: rollout_directories_to_sync(&path)?,
meta: None,
cwd: cwd.clone(),
rollout_path: path,
@@ -1716,6 +1722,21 @@ fn precompute_new_rollout_path(
Ok((path, timestamp))
}
#[cfg(unix)]
fn rollout_directories_to_sync(path: &Path) -> std::io::Result<Vec<PathBuf>> {
// Creating the file changes its parent. Creating a date directory changes its parent too.
let mut directories = Vec::new();
if let Some(parent) = path.parent() {
for directory in parent.ancestors() {
directories.push(directory.to_owned());
if directory.try_exists()? {
break;
}
}
}
Ok(directories)
}
fn open_log_file(path: &Path) -> std::io::Result<File> {
let refresh_modified_time = !compression::plain_rollout_path(path).try_exists()?;
let path = compression::materialize_rollout_for_append_blocking(path)?;
@@ -1730,7 +1751,7 @@ fn open_log_file(path: &Path) -> std::io::Result<File> {
.read(true)
.append(true)
.create(true)
.open(path)?;
.open(&path)?;
if refresh_modified_time {
refresh_rollout_modified_time(&file)?;
}
@@ -1758,6 +1779,11 @@ struct RolloutWriterState {
/// True until a newly created rollout is first materialized.
deferred_creation: bool,
pending_items: Vec<RolloutItem>,
/// A sealed V1 event requires a disk barrier, even if background append drained its item.
realtime_sync_pending: bool,
/// Directory entries that must reach disk before the first realtime seal is acknowledged.
#[cfg(unix)]
directories_to_sync: Vec<PathBuf>,
meta: Option<SessionMeta>,
cwd: PathBuf,
rollout_path: PathBuf,
@@ -1767,6 +1793,9 @@ struct RolloutWriterState {
impl RolloutWriterState {
fn add_items(&mut self, items: Vec<RolloutItem>) {
self.realtime_sync_pending |= items.iter().any(
|item| matches!(item, RolloutItem::RealtimeItem(item) if item.observed_at_ms.is_some()),
);
self.pending_items.extend(items);
}
@@ -1774,7 +1803,7 @@ impl RolloutWriterState {
if self.is_deferred() {
return;
}
if let Err(err) = self.flush().await {
if let Err(err) = self.write_pending_with_recovery("append").await {
self.enter_recovery_mode(&err);
}
}
@@ -1787,7 +1816,21 @@ impl RolloutWriterState {
if self.is_deferred() && self.pending_items.is_empty() {
return Ok(());
}
self.write_pending_with_recovery("flush").await
self.write_pending_with_recovery("flush").await?;
if self.realtime_sync_pending
&& let Some(writer) = self.writer.as_mut()
{
writer.file.sync_data().await?;
#[cfg(unix)]
{
for directory in &self.directories_to_sync {
tokio::fs::File::open(directory).await?.sync_all().await?;
}
self.directories_to_sync.clear();
}
self.realtime_sync_pending = false;
}
Ok(())
}
async fn shutdown(&mut self) -> std::io::Result<()> {
@@ -1848,6 +1891,10 @@ impl RolloutWriterState {
return Ok(());
}
#[cfg(unix)]
if self.directories_to_sync.is_empty() {
self.directories_to_sync = rollout_directories_to_sync(&self.rollout_path)?;
}
let file = open_log_file(self.rollout_path.as_path())?;
self.writer = Some(JsonlWriter {
file: tokio::fs::File::from_std(file),
+56
View File
@@ -1029,6 +1029,59 @@ async fn persist_reports_filesystem_error_and_retries_buffered_items() -> std::i
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn first_realtime_seal_requires_directory_sync_after_ordinary_writes() -> std::io::Result<()>
{
use std::os::unix::fs::PermissionsExt;
let home = TempDir::new()?;
let config = test_config(home.path());
let recorder = RolloutRecorder::new(
&config,
RolloutRecorderParams::new(
ThreadId::new(),
/*forked_from_id*/ None,
/*parent_thread_id*/ None,
SessionSource::Exec,
/*thread_source*/ None,
"test_originator".to_string(),
BaseInstructions::default(),
Vec::new(),
),
)
.await?;
let parent = recorder.rollout_path().parent().expect("rollout directory");
fs::create_dir_all(parent)?;
// Creating and appending a file only needs write/search access to the directory.
let permissions = fs::metadata(parent)?.permissions();
fs::set_permissions(parent, fs::Permissions::from_mode(0o300))?;
recorder
.record_canonical_items(&[agent_message_item("typed")])
.await?;
let ordinary_flush = recorder.flush().await;
fs::set_permissions(parent, permissions)?;
ordinary_flush?;
let record = RolloutItem::RealtimeItem(codex_protocol::realtime::RealtimeItem {
id: "opening".to_string(),
realtime_session_id: "voice".to_string(),
observed_at_ms: Some(1),
content: codex_protocol::realtime::RealtimeItemContent::RealtimeSessionStarted,
});
recorder.record_canonical_items(&[record]).await?;
// The open file still accepts writes. Only the directory barrier can see this failure.
let moved = home.path().join("moved");
fs::rename(parent, &moved)?;
assert_eq!(
recorder.flush().await.unwrap_err().kind(),
std::io::ErrorKind::NotFound
);
fs::rename(moved, parent)?;
recorder.flush().await?;
recorder.shutdown().await
}
#[tokio::test]
async fn writer_state_retries_write_error_before_reporting_flush_success() -> std::io::Result<()> {
let home = TempDir::new().expect("temp dir");
@@ -1040,6 +1093,9 @@ async fn writer_state_retries_write_error_before_reporting_flush_success() -> st
file: tokio::fs::File::from_std(read_only_file),
}),
deferred_creation: false,
realtime_sync_pending: false,
#[cfg(unix)]
directories_to_sync: Vec::new(),
pending_items: Vec::new(),
meta: None,
cwd: home.path().to_path_buf(),
+4
View File
@@ -798,6 +798,10 @@ impl ThreadStore for LocalThreadStore {
Box::pin(async move { projects::delete_project(self, project_id).await })
}
fn realtime_frames_enabled(&self, _thread_id: ThreadId) -> ThreadStoreFuture<'_, bool> {
Box::pin(async { Ok(self.state_db.is_some()) })
}
fn supports_paginated_history_lists(&self) -> bool {
self.state_db.is_some()
}
@@ -283,6 +283,7 @@ async fn fork_version_stops_before_older_segments_once_resolved() {
next_item_ordinal: None,
rejected_audio_file_ids: Default::default(),
dynamic_tools: None,
observation_clock_floor_ms: None,
multi_agent_version: Some(MultiAgentVersion::V2),
last_started_turn_id: None,
turn_attribution: None,
@@ -430,6 +430,7 @@ async fn timeline_interleaves_items_and_restores_page_boundary_session_state() {
RealtimeItem {
id: "voice-1:started".to_string(),
realtime_session_id: "voice-1".to_string(),
observed_at_ms: None,
content: RealtimeItemContent::RealtimeSessionStarted,
},
),
@@ -438,6 +439,7 @@ async fn timeline_interleaves_items_and_restores_page_boundary_session_state() {
RealtimeItem {
id: "voice-1:source:0".to_string(),
realtime_session_id: "voice-1".to_string(),
observed_at_ms: None,
content: RealtimeItemContent::TranscriptSegment {
role: RealtimeTranscriptRole::Assistant,
text: "Before the artifact".to_string(),
@@ -449,6 +451,7 @@ async fn timeline_interleaves_items_and_restores_page_boundary_session_state() {
RealtimeItem {
id: "artifact".to_string(),
realtime_session_id: "voice-1".to_string(),
observed_at_ms: None,
content: RealtimeItemContent::BemItemPromoted {
turn_id: "turn-1".to_string(),
item_id: "middle".to_string(),
@@ -461,6 +464,7 @@ async fn timeline_interleaves_items_and_restores_page_boundary_session_state() {
RealtimeItem {
id: "voice-1:closed".to_string(),
realtime_session_id: "voice-1".to_string(),
observed_at_ms: None,
content: RealtimeItemContent::RealtimeSessionClosed {
outcome: RealtimeSessionOutcome::Ended,
},
@@ -727,6 +731,7 @@ async fn timeline_page_state_excludes_realtime_events_after_fork_cutoff() {
RealtimeItem {
id: "voice:started".to_string(),
realtime_session_id: "voice".to_string(),
observed_at_ms: None,
content: RealtimeItemContent::RealtimeSessionStarted,
},
),
@@ -735,6 +740,7 @@ async fn timeline_page_state_excludes_realtime_events_after_fork_cutoff() {
RealtimeItem {
id: "voice:closed".to_string(),
realtime_session_id: "voice".to_string(),
observed_at_ms: None,
content: RealtimeItemContent::RealtimeSessionClosed {
outcome: RealtimeSessionOutcome::Ended,
},
@@ -130,6 +130,7 @@ ORDER BY rollout_ordinal DESC, item_id DESC LIMIT ?7
SELECT rollout_ordinal, 2 AS kind, item_id AS id, NULL AS turn_id, item_json
FROM thread_realtime_items
WHERE thread_id = ?1 AND rollout_ordinal >= ?2
AND item_type NOT IN ('turn_input_accepted', 'session_end_input_saved')
AND rollout_ordinal < ?3 AND rollout_ordinal <= ?4
AND (rollout_ordinal, 2, item_id) < (?4, ?5, ?6)
ORDER BY rollout_ordinal DESC, item_id DESC LIMIT ?7
@@ -626,11 +626,13 @@ async fn paginated_realtime_items_materialize_separately_in_rollout_order() {
RolloutItem::RealtimeItem(RealtimeItem {
id: "voice:started".to_string(),
realtime_session_id: "voice".to_string(),
observed_at_ms: None,
content: RealtimeItemContent::RealtimeSessionStarted,
}),
RolloutItem::RealtimeItem(RealtimeItem {
id: "voice:transcript:0".to_string(),
realtime_session_id: "voice".to_string(),
observed_at_ms: None,
content: RealtimeItemContent::TranscriptSegment {
role: RealtimeTranscriptRole::Assistant,
text: "Here is the result.".to_string(),
@@ -640,6 +642,7 @@ async fn paginated_realtime_items_materialize_separately_in_rollout_order() {
RolloutItem::RealtimeItem(RealtimeItem {
id: "voice:promoted:agent-1".to_string(),
realtime_session_id: "voice".to_string(),
observed_at_ms: None,
content: RealtimeItemContent::BemItemPromoted {
turn_id: "turn-1".to_string(),
item_id: "agent-1".to_string(),
@@ -664,6 +667,7 @@ async fn paginated_realtime_items_materialize_separately_in_rollout_order() {
RolloutItem::RealtimeItem(RealtimeItem {
id: "voice:closed".to_string(),
realtime_session_id: "voice".to_string(),
observed_at_ms: None,
content: RealtimeItemContent::RealtimeSessionClosed {
outcome: RealtimeSessionOutcome::Ended,
},
@@ -797,6 +801,7 @@ async fn paginated_realtime_items_materialize_separately_in_rollout_order() {
items: vec![RolloutItem::RealtimeItem(RealtimeItem {
id: "legacy:started".to_string(),
realtime_session_id: "legacy".to_string(),
observed_at_ms: None,
content: RealtimeItemContent::RealtimeSessionStarted,
})],
})
@@ -1487,6 +1492,7 @@ async fn subagent_prefix_advances_projection_without_materializing_history() {
RolloutItem::RealtimeItem(RealtimeItem {
id: "parent:started".to_string(),
realtime_session_id: "parent".to_string(),
observed_at_ms: None,
content: RealtimeItemContent::RealtimeSessionStarted,
}),
turn_started("parent-turn"),
@@ -1504,6 +1510,7 @@ async fn subagent_prefix_advances_projection_without_materializing_history() {
RolloutItem::RealtimeItem(RealtimeItem {
id: "child:started".to_string(),
realtime_session_id: "child".to_string(),
observed_at_ms: None,
content: RealtimeItemContent::RealtimeSessionStarted,
}),
completed_item(
+5
View File
@@ -547,6 +547,11 @@ pub trait ThreadStore: Any + Send + Sync {
})
}
/// Whether this live thread writes canonical realtime frames and handoff receipts.
fn realtime_frames_enabled(&self, _thread_id: ThreadId) -> ThreadStoreFuture<'_, bool> {
Box::pin(async { Ok(false) })
}
/// Whether paginated threads can hydrate durable history through turn and item lists.
fn supports_paginated_history_lists(&self) -> bool {
false
+117 -1
View File
@@ -6423,6 +6423,11 @@ class ThreadRealtimeErrorNotification(BaseModel):
thread_id: Annotated[str, Field(alias="threadId")]
class ThreadRealtimeHandoffMode(Enum):
start = "start"
steer = "steer"
class ThreadRealtimeInitialItem(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -6436,6 +6441,13 @@ class RealtimeSessionStartedThreadRealtimeItem(BaseModel):
populate_by_name=True,
)
id: str
observed_at_ms: Annotated[
int | None,
Field(
alias="observedAtMs",
description="Canonical observation time in Unix milliseconds for eligible voice calls.",
),
] = None
realtime_session_id: Annotated[str, Field(alias="realtimeSessionId")]
type: Annotated[
Literal["realtimeSessionStarted"],
@@ -6448,6 +6460,13 @@ class BemItemPromotedThreadRealtimeItem(BaseModel):
populate_by_name=True,
)
id: str
observed_at_ms: Annotated[
int | None,
Field(
alias="observedAtMs",
description="Canonical observation time in Unix milliseconds for eligible voice calls.",
),
] = None
realtime_session_id: Annotated[str, Field(alias="realtimeSessionId")]
item_id: str
presentation: ThreadRealtimeBemItemPresentation
@@ -6457,6 +6476,51 @@ class BemItemPromotedThreadRealtimeItem(BaseModel):
]
class TurnInputAcceptedThreadRealtimeItem(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
id: str
observed_at_ms: Annotated[
int | None,
Field(
alias="observedAtMs",
description="Canonical observation time in Unix milliseconds for eligible voice calls.",
),
] = None
realtime_session_id: Annotated[str, Field(alias="realtimeSessionId")]
handoff_id: str
input: str
input_item_id: str
mode: ThreadRealtimeHandoffMode
source_event_ids: list[str]
turn_id: str
type: Annotated[
Literal["turnInputAccepted"], Field(title="TurnInputAcceptedThreadRealtimeItemType")
]
class SessionEndInputSavedThreadRealtimeItem(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
id: str
observed_at_ms: Annotated[
int | None,
Field(
alias="observedAtMs",
description="Canonical observation time in Unix milliseconds for eligible voice calls.",
),
] = None
realtime_session_id: Annotated[str, Field(alias="realtimeSessionId")]
input: str
input_item_id: str
source_event_ids: list[str]
type: Annotated[
Literal["sessionEndInputSaved"], Field(title="SessionEndInputSavedThreadRealtimeItemType")
]
class ThreadRealtimeItemAddedNotification(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -6578,6 +6642,14 @@ class ThreadRealtimeTranscriptRole(Enum):
assistant = "assistant"
class ThreadRealtimeVoiceTranscript(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
role: ThreadRealtimeTranscriptRole
text: str
class ThreadResumeParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -11238,11 +11310,42 @@ class ThreadReadStateChangedNotification(BaseModel):
thread_id: Annotated[str, Field(alias="threadId")]
class ThreadRealtimeFrameChannels(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
voice_transcript: ThreadRealtimeVoiceTranscript
class FrameThreadRealtimeItem(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
id: str
observed_at_ms: Annotated[
int | None,
Field(
alias="observedAtMs",
description="Canonical observation time in Unix milliseconds for eligible voice calls.",
),
] = None
realtime_session_id: Annotated[str, Field(alias="realtimeSessionId")]
channels: ThreadRealtimeFrameChannels
type: Annotated[Literal["frame"], Field(title="FrameThreadRealtimeItemType")]
class TranscriptSegmentThreadRealtimeItem(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
id: str
observed_at_ms: Annotated[
int | None,
Field(
alias="observedAtMs",
description="Canonical observation time in Unix milliseconds for eligible voice calls.",
),
] = None
realtime_session_id: Annotated[str, Field(alias="realtimeSessionId")]
role: ThreadRealtimeTranscriptRole
text: str
@@ -11256,6 +11359,13 @@ class RealtimeSessionClosedThreadRealtimeItem(BaseModel):
populate_by_name=True,
)
id: str
observed_at_ms: Annotated[
int | None,
Field(
alias="observedAtMs",
description="Canonical observation time in Unix milliseconds for eligible voice calls.",
),
] = None
realtime_session_id: Annotated[str, Field(alias="realtimeSessionId")]
outcome: ThreadRealtimeSessionOutcome
type: Annotated[
@@ -11266,16 +11376,22 @@ class RealtimeSessionClosedThreadRealtimeItem(BaseModel):
class ThreadRealtimeItem(
RootModel[
RealtimeSessionStartedThreadRealtimeItem
| FrameThreadRealtimeItem
| TranscriptSegmentThreadRealtimeItem
| BemItemPromotedThreadRealtimeItem
| RealtimeSessionClosedThreadRealtimeItem
| TurnInputAcceptedThreadRealtimeItem
| SessionEndInputSavedThreadRealtimeItem
]
):
root: Annotated[
RealtimeSessionStartedThreadRealtimeItem
| FrameThreadRealtimeItem
| TranscriptSegmentThreadRealtimeItem
| BemItemPromotedThreadRealtimeItem
| RealtimeSessionClosedThreadRealtimeItem,
| RealtimeSessionClosedThreadRealtimeItem
| TurnInputAcceptedThreadRealtimeItem
| SessionEndInputSavedThreadRealtimeItem,
Field(
description="EXPERIMENTAL - a thread-scoped realtime item in the canonical timeline."
),