From 9dd2b421931cc1ee7c84132491120f8e444c0af0 Mon Sep 17 00:00:00 2001 From: Corneliu Croitoru Date: Thu, 9 Jul 2026 17:30:29 +0200 Subject: [PATCH 1/2] fix(swift): anchor realtime answer-generation span to response.created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `response` (single) and `presenter`/direct (grounded) generation spans were created AND ended in the same synchronous block at `response.done`, so their exported latency was ~0 — in LangSmith the realtime LLM calls all showed 0.00s, while the non-realtime `llm.completion` spans (opened around the actual call) showed real durations. The OpenAI Realtime API sends no server-side timing (no timestamps on events, nothing in response.done/usage/ status_details), so the duration can only be measured client-side from the `response.created` → `response.done` receive-times. Backdate the generation's start to the response's `created` receive-time: - Tracing: add a defaulted `SpanHandle.generation(_:model:input:startedAt:)` overload (default ignores it and stamps now, so every existing conformer — OSLogTracer, test doubles, consumer custom tracers — keeps compiling); ProcessedSpan overrides it and threads `startedAt` through `child(...)`. - OpenAIVoiceAssistant: record `responseStartedAt[id]` at `.responseCreated`, pass it to the `response` generation, clear it in `resetTurn`. - OpenAIGroundedVoiceAssistant: record `presenterStartedAt` when the presenter/direct response is created, pass it to the `presenter` generation, clear on finish/fail. The generation now measures the true synthesis latency; tool time still shows as sibling `tool.*` spans and the `voice.turn` root keeps its full duration. Tests: RecordingTracer captures per-span start/end and the new overload; ProcessingTracerTests proves the backdate survives export; each assistant has a test that a real gap between response.created and response.done is reflected in the generation's duration (response, presenter, and direct paths). Co-authored-by: Claude --- swift/SKILL.md | 6 ++- .../Core/Tracing/ProcessingTracer.swift | 8 +++- .../AgentSquad/Core/Tracing/Tracing.swift | 10 +++++ .../OpenAIGroundedVoiceAssistant.swift | 11 ++++- .../Realtime/OpenAIVoiceAssistant.swift | 11 ++++- .../OpenAIGroundedVoiceAssistantTests.swift | 44 +++++++++++++++++++ .../OpenAIVoiceAssistantTests.swift | 20 +++++++++ .../ProcessingTracerTests.swift | 19 ++++++++ .../AgentSquadTests/RealtimeTestSupport.swift | 9 +++- 9 files changed, 132 insertions(+), 6 deletions(-) diff --git a/swift/SKILL.md b/swift/SKILL.md index 2aad79ab..1027c382 100644 --- a/swift/SKILL.md +++ b/swift/SKILL.md @@ -125,7 +125,11 @@ signatures live in `Sources/AgentSquad/`. - **Storage**: `FileChatStorage` (JSON, iOS 16+, scopes per-call by `userId`/`sessionId`/`agentId` — e.g. `sessionId` to isolate per match) or `DeviceChatStorage` (SwiftData, iOS 17+, bound to one `userId`). Both default to Library/Caches (disposable). `InMemoryChatStorage` (iOS 16+) is non-persistent and holds one conversation — construct it empty or seeded with a prior conversation to load one into a session. Wrap any store in `TransformingChatStorage` to scrub/redact before persistence; prefer redacting over returning `nil` (dropping one side of an exchange can make the store skip its counterpart via the consecutive-same-role guard). - **Tracing lifecycle**: nothing drains the tracer for you — flush on background, shut down on termination. `OSLogTracer` logs no payloads. `Redaction` hashes ids + clips strings but does **not** - pattern-scrub PII — supply a custom `Redactor` for that. + pattern-scrub PII — supply a custom `Redactor` for that. A realtime answer generation + (`response`/`presenter`) is backdated to its `response.created` receive-time via + `SpanHandle.generation(…, startedAt:)`, so the exported span carries the real call latency instead + of a ~0 duration (the Realtime API sends no server-side timing). The overload defaults to stamping + now, so custom `SpanHandle`s need not implement it. - **Realtime** is a peer runtime, not an agent; its `events` stream is non-throwing; needs `NSMicrophoneUsageDescription`; always `stop()`. In-band failures arrive as `.error(code:message:)` — `code` is the API's machine code (e.g. `rate_limit_exceeded`), diff --git a/swift/Sources/AgentSquad/Core/Tracing/ProcessingTracer.swift b/swift/Sources/AgentSquad/Core/Tracing/ProcessingTracer.swift index 52417b01..22daea00 100644 --- a/swift/Sources/AgentSquad/Core/Tracing/ProcessingTracer.swift +++ b/swift/Sources/AgentSquad/Core/Tracing/ProcessingTracer.swift @@ -53,6 +53,10 @@ struct ProcessedSpan: GenerationHandle { child(kind: .generation, name: name, input: input, model: model) } + func generation(_ name: String, model: String, input: JSONValue?, startedAt: Date) -> any GenerationHandle { + child(kind: .generation, name: name, input: input, model: model, startedAt: startedAt) + } + func usage(promptTokens: Int?, completionTokens: Int?) { processor.onUsage(id: id, promptTokens: promptTokens, completionTokens: completionTokens) } @@ -70,8 +74,8 @@ struct ProcessedSpan: GenerationHandle { processor.onEnd(id: id, endedAt: Date(), output: output, error: error.map { String(reflecting: $0) }) } - private func child(kind: TraceEvent.Kind, name: String, input: JSONValue?, model: String?) -> ProcessedSpan { - let child = ProcessedSpan(traceId: traceId, id: UUID().uuidString, parentId: id, startedAt: Date(), userId: userId, sessionId: sessionId, processor: processor) + private func child(kind: TraceEvent.Kind, name: String, input: JSONValue?, model: String?, startedAt: Date = Date()) -> ProcessedSpan { + let child = ProcessedSpan(traceId: traceId, id: UUID().uuidString, parentId: id, startedAt: startedAt, userId: userId, sessionId: sessionId, processor: processor) processor.onOpen(SpanData( id: child.id, traceId: traceId, parentId: child.parentId, kind: kind, name: name, startedAt: child.startedAt, input: input, model: model, userId: userId, sessionId: sessionId diff --git a/swift/Sources/AgentSquad/Core/Tracing/Tracing.swift b/swift/Sources/AgentSquad/Core/Tracing/Tracing.swift index 11dce533..8cb4b5b3 100644 --- a/swift/Sources/AgentSquad/Core/Tracing/Tracing.swift +++ b/swift/Sources/AgentSquad/Core/Tracing/Tracing.swift @@ -34,6 +34,11 @@ public protocol SpanHandle: Sendable { func span(_ name: String, input: JSONValue?) -> any SpanHandle /// Open a child generation — an LLM call (records model + token usage). func generation(_ name: String, model: String, input: JSONValue?) -> any GenerationHandle + /// Open a child generation whose start is backdated to `startedAt` — for a call whose real start + /// (e.g. a realtime `response.created`) precedes the moment we can materialize the span. Lets the + /// exported span carry the true call latency instead of a ~0 duration. Defaults to ignoring + /// `startedAt` (stamping now), so a stateless tracer need not implement it. + func generation(_ name: String, model: String, input: JSONValue?, startedAt: Date) -> any GenerationHandle /// Set the input once it's known later than open (e.g. a transcript arriving after the turn span). func setInput(_ input: JSONValue) /// Attach metadata — a `.object` whose top-level keys ride to the backend as span attributes @@ -47,6 +52,11 @@ public protocol SpanHandle: Sendable { extension SpanHandle { public func setInput(_ input: JSONValue) {} public func setMetadata(_ metadata: JSONValue) {} + /// Default: ignore the backdate and stamp the start now — implementers that don't track a + /// separate start time keep working unchanged. + public func generation(_ name: String, model: String, input: JSONValue?, startedAt: Date) -> any GenerationHandle { + generation(name, model: model, input: input) + } } /// A span for an LLM call; adds token accounting. diff --git a/swift/Sources/AgentSquad/Runtimes/Realtime/OpenAIGroundedVoiceAssistant.swift b/swift/Sources/AgentSquad/Runtimes/Realtime/OpenAIGroundedVoiceAssistant.swift index 46fb00b7..59ad4a16 100644 --- a/swift/Sources/AgentSquad/Runtimes/Realtime/OpenAIGroundedVoiceAssistant.swift +++ b/swift/Sources/AgentSquad/Runtimes/Realtime/OpenAIGroundedVoiceAssistant.swift @@ -47,6 +47,9 @@ public actor OpenAIGroundedVoiceAssistant: OpenAIRealtimeSession, VoiceAssistant private var presenterId: String? private var presenterResponses: Set = [] private var presenterActive = false + // When the spoken (presenter/direct) response was created on the wire, so its `presenter` + // generation span can be backdated to it for a real latency (the API sends no timing). + private var presenterStartedAt: Date? // Barge-in truncation: which audio item is playing + how much was sent, vs. the runtime's // played-ms clock. NOT cleared by `resetTurn` — `interrupt()` reads it after resetting the turn. // Only the in-band `direct` reply is truncatable: the presenter is out-of-band @@ -223,6 +226,7 @@ public actor OpenAIGroundedVoiceAssistant: OpenAIRealtimeSession, VoiceAssistant if (role == "presenter" || role == "direct"), !id.isEmpty { presenterResponses.insert(id) presenterId = id + presenterStartedAt = Date() // anchor the presenter generation's latency to now (created) spokenResponseIsInBand = role == "direct" } case .responseDone(let id, let usage, let status): @@ -256,13 +260,17 @@ public actor OpenAIGroundedVoiceAssistant: OpenAIRealtimeSession, VoiceAssistant presenterId = nil presenterActive = false truncation.reset() // clean finish — nothing left to truncate + defer { presenterStartedAt = nil } let (user, reply) = (pendingUserText, replyText) // snapshot + clear before the store await // Record the spoken answer as a `presenter` generation (question in, reply out, presenter // usage) under the turn, then close the turn. The gatherer's tool calls show as the turn's // `tool.*` child spans, but its token usage is dropped this pass — so this count is the // presenter's, not the turn total. if let turn = turnSpan { - let presenter = turn.generation("presenter", model: model, input: transcript(user)) + // Backdate the generation to the presenter/direct response's `created` receive-time + // so it carries the real LLM latency, not a ~0-duration span (the API sends no timing). + let presenter = turn.generation("presenter", model: model, input: transcript(user), + startedAt: presenterStartedAt ?? Date()) presenter.usage(promptTokens: usage?.inputTokens, completionTokens: usage?.outputTokens) if let breakdown = usage?.breakdownMetadata { presenter.setMetadata(breakdown) } presenter.end(output: transcript(reply), error: nil) @@ -334,6 +342,7 @@ public actor OpenAIGroundedVoiceAssistant: OpenAIRealtimeSession, VoiceAssistant presenterResponses.remove(id) if id == presenterId { presenterId = nil } presenterActive = false + presenterStartedAt = nil callsPerResponse[id] = nil truncation.reset() endTurn(error: RealtimeSessionError.responseFailed(detail: detail)) diff --git a/swift/Sources/AgentSquad/Runtimes/Realtime/OpenAIVoiceAssistant.swift b/swift/Sources/AgentSquad/Runtimes/Realtime/OpenAIVoiceAssistant.swift index f6803066..cac1fcd4 100644 --- a/swift/Sources/AgentSquad/Runtimes/Realtime/OpenAIVoiceAssistant.swift +++ b/swift/Sources/AgentSquad/Runtimes/Realtime/OpenAIVoiceAssistant.swift @@ -54,6 +54,10 @@ public actor OpenAIVoiceAssistant: OpenAIRealtimeSession, VoiceAssistant { private var liveResponses: Set = [] private var currentResponseId: String? private var isSpeaking = false + // When each response was created on the wire (client receive-time of `response.created`), so the + // answer generation span can be backdated to it — the OpenAI Realtime API sends no timing, and + // materializing the span at `response.done` alone would give it ~0 latency. + private var responseStartedAt: [String: Date] = [:] // Barge-in truncation: which audio item is playing + how much was sent, vs. the runtime's // played-ms clock. NOT cleared by `resetTurn` — `interrupt()` reads it after resetting the turn. private var truncation = AudioTruncationState() @@ -226,6 +230,7 @@ public actor OpenAIVoiceAssistant: OpenAIRealtimeSession, VoiceAssistant { liveResponses.insert(id) currentResponseId = id isSpeaking = true + responseStartedAt[id] = Date() // anchor the answer generation's latency to now (created) case .responseDone(let id, let usage, let status): await responseDone(id, usage: usage, status: status) case .error(let code, let message): @@ -272,7 +277,10 @@ public actor OpenAIVoiceAssistant: OpenAIRealtimeSession, VoiceAssistant { // Record the spoken exchange (transcript in/out + usage) as a generation under the turn, then // close the turn with the reply — so the trace shows what was said, not an empty span. if let turn = turnSpan { - let exchange = turn.generation("response", model: model, input: transcript(user)) + // Backdate the generation to the response's `created` receive-time so it carries the real + // LLM latency (the API sends no timing; stamping it now would give a ~0-duration span). + let exchange = turn.generation("response", model: model, input: transcript(user), + startedAt: responseStartedAt[id] ?? Date()) exchange.usage(promptTokens: usage?.inputTokens, completionTokens: usage?.outputTokens) if let breakdown = usage?.breakdownMetadata { exchange.setMetadata(breakdown) } exchange.end(output: transcript(reply), error: nil) @@ -316,6 +324,7 @@ public actor OpenAIVoiceAssistant: OpenAIRealtimeSession, VoiceAssistant { userText = "" replyText = "" callsPerResponse.removeAll() + responseStartedAt.removeAll() fnNames.removeAll() currentTurnIsTextOnly = false // next turn defaults to the session modality unless `sendText` opts in turnReasoningEffort = nil // reasoning escalation is per turn diff --git a/swift/Tests/AgentSquadTests/OpenAIGroundedVoiceAssistantTests.swift b/swift/Tests/AgentSquadTests/OpenAIGroundedVoiceAssistantTests.swift index b58965d8..24df408e 100644 --- a/swift/Tests/AgentSquadTests/OpenAIGroundedVoiceAssistantTests.swift +++ b/swift/Tests/AgentSquadTests/OpenAIGroundedVoiceAssistantTests.swift @@ -357,6 +357,50 @@ import Testing #expect(tracer.recorder.output("voice.turn") == .string("PSG is favourite at 2.5")) } + @Test func presenterGenerationSpanMeasuresRealLatencyFromResponseCreated() async throws { + let transport = MockRealtimeTransport() + let tracer = RecordingTracer() + let log = EventLog() + let session = session(transport, tools: oddsTools(), tracer: tracer) + log.start(session) + try await session.start() + + transport.push(responseCreated("r1")) + transport.push(funcArgs("r1", "c1", "odds")) + transport.push(responseDone("r1")) // tools → continue + transport.push(responseDone("r2")) // settled → present() + await eventually { log.states.contains(.presenting) } // gatherer drained, pump idle + transport.push(responseCreated("p1", role: "presenter")) // presenter starts (span start anchor) + try await Task.sleep(for: .milliseconds(50)) // …presenter "generates"… + transport.push(responseDone("p1")) // presenter finishes (span end anchor) + + await eventually { tracer.recorder.ended.contains("presenter") } + // Backdated to the presenter response.created, so the generation carries the real latency + // instead of the ~0s it showed when created-and-ended at response.done. + let latency = try #require(tracer.recorder.duration("presenter")) + #expect(latency >= 0.02) + } + + @Test func directGenerationSpanMeasuresRealLatencyFromResponseCreated() async throws { + let transport = MockRealtimeTransport() + let tracer = RecordingTracer() + let log = EventLog() + let session = session(transport, tools: oddsTools(), tracer: tracer) + log.start(session) + try await session.start() + + transport.push(#"{"type":"response.output_text.delta","response_id":"agent","delta":"hi"}"#) + transport.push(responseDone("agent")) // no tools, has text → speakDirectly() + await eventually { log.states.contains(.speaking) } // speakDirectly ran, pump idle + transport.push(responseCreated("d1", role: "direct")) // direct reply starts (span start anchor) + try await Task.sleep(for: .milliseconds(50)) // …direct reply "generates"… + transport.push(responseDone("d1")) // direct reply finishes (span end anchor) + + await eventually { tracer.recorder.ended.contains("presenter") } // the direct answer is the `presenter` generation + let latency = try #require(tracer.recorder.duration("presenter")) + #expect(latency >= 0.02) + } + @Test func attachesAudioTokenBreakdownToThePresenterGeneration() async throws { let transport = MockRealtimeTransport() let tracer = RecordingTracer() diff --git a/swift/Tests/AgentSquadTests/OpenAIVoiceAssistantTests.swift b/swift/Tests/AgentSquadTests/OpenAIVoiceAssistantTests.swift index e0766d67..6498bed9 100644 --- a/swift/Tests/AgentSquadTests/OpenAIVoiceAssistantTests.swift +++ b/swift/Tests/AgentSquadTests/OpenAIVoiceAssistantTests.swift @@ -250,6 +250,26 @@ import Testing #expect(tracer.recorder.output("voice.turn") == .string("PSG is favourite at 2.5")) } + @Test func responseGenerationSpanMeasuresRealLatencyFromResponseCreated() async throws { + let transport = MockRealtimeTransport() + let tracer = RecordingTracer() + let log = EventLog() + let session = session(transport, tracer: tracer) + log.start(session) + try await session.start() + + transport.push(responseCreated("r1")) // the answer starts here (span start anchor) + await eventually { log.states.contains(.speaking) } // …created processed, startedAt stamped… + try await Task.sleep(for: .milliseconds(50)) // …the model "generates" for a bit… + transport.push(responseDone("r1")) // …and finishes here (span end anchor) + + await eventually { tracer.recorder.ended.contains("response") } + // The `response` generation is backdated to response.created, so its duration reflects the real + // LLM latency (previously it was created-and-ended at response.done → ~0s, the LangSmith bug). + let latency = try #require(tracer.recorder.duration("response")) + #expect(latency >= 0.02) + } + @Test func traceTranscriptsOffOmitsSpokenTextButKeepsStructureAndUsage() async throws { let transport = MockRealtimeTransport() let tracer = RecordingTracer() diff --git a/swift/Tests/AgentSquadTests/ProcessingTracerTests.swift b/swift/Tests/AgentSquadTests/ProcessingTracerTests.swift index 92a7ca01..aff8ffb1 100644 --- a/swift/Tests/AgentSquadTests/ProcessingTracerTests.swift +++ b/swift/Tests/AgentSquadTests/ProcessingTracerTests.swift @@ -107,6 +107,25 @@ import Testing #expect(genEvent.completionTokens == 5) } + @Test func generationBackdatesStartedAtToTheGivenTime() async throws { + let exporter = RecordingExporter() + let tracer = ProcessingTracer(exporter: exporter, batchSize: 100) + + // A realtime answer whose call started well before we could materialize the span: backdating + // `startedAt` makes the exported span carry the real latency instead of a ~0 duration. + let start = Date(timeIntervalSince1970: 1_000) + let root = tracer.startTrace(name: "turn", userId: nil, sessionId: nil, metadata: nil) + let gen = root.generation("response", model: "gpt", input: nil, startedAt: start) + gen.end(output: nil, error: nil) + root.end(output: nil, error: nil) + try await tracer.flush() + + let genEvent = try #require(await exporter.events.first { $0.kind == .generation }) + #expect(genEvent.startedAt == start) // the backdate survives finalize + let ended = try #require(genEvent.endedAt) + #expect(ended.timeIntervalSince(start) > 0) // and yields a non-zero latency + } + @Test func setInputAfterOpenIsAppliedToTheExportedSpan() async throws { let exporter = RecordingExporter() let tracer = ProcessingTracer(exporter: exporter, batchSize: 100) diff --git a/swift/Tests/AgentSquadTests/RealtimeTestSupport.swift b/swift/Tests/AgentSquadTests/RealtimeTestSupport.swift index b6f1129e..5da528c9 100644 --- a/swift/Tests/AgentSquadTests/RealtimeTestSupport.swift +++ b/swift/Tests/AgentSquadTests/RealtimeTestSupport.swift @@ -75,12 +75,15 @@ final class RecordingTracer: Tracer, @unchecked Sendable { private var _usage: [String: (Int?, Int?)] = [:] private var _metadata: [String: JSONValue] = [:] private var _errors: [String: String] = [:] - func open(_ n: String, input: JSONValue? = nil) { lock.withLock { _opened.append(n); if let input { _input[n] = input } } } + private var _startedAt: [String: Date] = [:] + private var _endedAt: [String: Date] = [:] + func open(_ n: String, input: JSONValue? = nil, startedAt: Date = Date()) { lock.withLock { _opened.append(n); _startedAt[n] = startedAt; if let input { _input[n] = input } } } func setInput(_ n: String, _ input: JSONValue) { lock.withLock { _input[n] = input } } func setMetadata(_ n: String, _ metadata: JSONValue) { lock.withLock { _metadata[n] = metadata } } func close(_ n: String, output: JSONValue?, error: (any Error)? = nil) { lock.withLock { _ended.append(n) + _endedAt[n] = Date() if let output { _output[n] = output } if let error { _errors[n] = String(reflecting: error) } // same projection the processors use } @@ -93,6 +96,9 @@ final class RecordingTracer: Tracer, @unchecked Sendable { func usage(_ n: String) -> (Int?, Int?)? { lock.withLock { _usage[n] } } func metadata(_ n: String) -> JSONValue? { lock.withLock { _metadata[n] } } func error(_ n: String) -> String? { lock.withLock { _errors[n] } } + /// The span's recorded latency (end − start). `startedAt` is the backdated value when the span + /// was opened via `generation(…startedAt:)`, so this asserts the real duration, not ~0. + func duration(_ n: String) -> TimeInterval? { lock.withLock { guard let s = _startedAt[n], let e = _endedAt[n] else { return nil }; return e.timeIntervalSince(s) } } } final class Span: GenerationHandle, @unchecked Sendable { let id: String @@ -100,6 +106,7 @@ final class RecordingTracer: Tracer, @unchecked Sendable { init(id: String, recorder: Recorder) { self.id = id; self.recorder = recorder } func span(_ name: String, input: JSONValue?) -> any SpanHandle { recorder.open(name, input: input); return Span(id: name, recorder: recorder) } func generation(_ name: String, model: String, input: JSONValue?) -> any GenerationHandle { recorder.open(name, input: input); return Span(id: name, recorder: recorder) } + func generation(_ name: String, model: String, input: JSONValue?, startedAt: Date) -> any GenerationHandle { recorder.open(name, input: input, startedAt: startedAt); return Span(id: name, recorder: recorder) } func setInput(_ input: JSONValue) { recorder.setInput(id, input) } func setMetadata(_ metadata: JSONValue) { recorder.setMetadata(id, metadata) } func end(output: JSONValue?, error: (any Error)?) { recorder.close(id, output: output, error: error) } From 0fce739e5b24a26ba69f095d1b6ce834b184701d Mon Sep 17 00:00:00 2001 From: Corneliu Croitoru Date: Thu, 9 Jul 2026 17:36:59 +0200 Subject: [PATCH 2/2] fix(swift): clear presenterStartedAt before persist await, not via defer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review (PR #577): the `defer { presenterStartedAt = nil }` in the grounded clean-finish block runs at scope exit — AFTER `await persist(...)`. Actors are reentrant at that await, so a next turn can process its presenter/direct response.created and set `presenterStartedAt` during the suspension; the deferred clear then wipes the NEW turn's timestamp, and its presenter generation falls back to Date() → ~0 latency again. Clear it synchronously before the await instead, matching the existing pendingUserText/replyText cleanup that guards the same reentrancy window. Co-authored-by: Claude --- .../Runtimes/Realtime/OpenAIGroundedVoiceAssistant.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/swift/Sources/AgentSquad/Runtimes/Realtime/OpenAIGroundedVoiceAssistant.swift b/swift/Sources/AgentSquad/Runtimes/Realtime/OpenAIGroundedVoiceAssistant.swift index 59ad4a16..e779e2ef 100644 --- a/swift/Sources/AgentSquad/Runtimes/Realtime/OpenAIGroundedVoiceAssistant.swift +++ b/swift/Sources/AgentSquad/Runtimes/Realtime/OpenAIGroundedVoiceAssistant.swift @@ -260,7 +260,6 @@ public actor OpenAIGroundedVoiceAssistant: OpenAIRealtimeSession, VoiceAssistant presenterId = nil presenterActive = false truncation.reset() // clean finish — nothing left to truncate - defer { presenterStartedAt = nil } let (user, reply) = (pendingUserText, replyText) // snapshot + clear before the store await // Record the spoken answer as a `presenter` generation (question in, reply out, presenter // usage) under the turn, then close the turn. The gatherer's tool calls show as the turn's @@ -283,6 +282,11 @@ public actor OpenAIGroundedVoiceAssistant: OpenAIRealtimeSession, VoiceAssistant emit(.state(.listening)) pendingUserText = "" replyText = "" + // Clear synchronously BEFORE the await: `persist` suspends the actor, and a reentrant + // next turn can set `presenterStartedAt` from its own response.created during that + // suspension — a `defer` after the await would wipe the new turn's timestamp and its + // presenter generation would fall back to Date() (~0 latency again). + presenterStartedAt = nil await persist(user: user, reply: reply) } // else: a response we cancelled on barge-in (`presenterId` already cleared). The server still