Skip to content

fix(desktop): keep PTT escalation available on a new voice session#9748

Open
kodjima33 wants to merge 1 commit into
mainfrom
watchdog/pr9734-ptt-escalation-new-session
Open

fix(desktop): keep PTT escalation available on a new voice session#9748
kodjima33 wants to merge 1 commit into
mainfrom
watchdog/pr9734-ptt-escalation-new-session

Conversation

@kodjima33

@kodjima33 kodjima33 commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Bug

After #9734, asking the floating bar a hard question over PTT on a new voice session fails instead of escalating. The user hears a refusal/error rather than an answer from the higher model.

Root cause

#9734 correctly started feeding the canonical kernel context into ask_higher_model, but its availability guard requires every field of the prefetched snapshot to be non-empty:

guard !kernelContext.rendered.isEmpty,
  !kernelContext.snapshotFreshnessIdentity.isEmpty,
  !kernelContext.semanticGuidance.isEmpty,
  !kernelContext.stableCacheIdentity.isEmpty,
  !kernelContext.dynamicContextIdentity.isEmpty,
  !kernelContext.planID.isEmpty
else { return .failed(...kernel_context_unavailable) }

Rendered context is context material, not an availability signal. KernelVoiceContextSnapshot.isResolved states the contract directly:

.empty is a transport/bridge failure sentinel, not a valid blank conversation. A valid new conversation may render no text, but it still has a kernel session and a deterministic freshness identity.

On the first PTT turn of a session there is nothing in the journal yet, so snapshot.renderedContext is empty — a perfectly resolved snapshot. The guard reads that as "kernel context unavailable" and hard-fails the tool.

RealtimeHubTools.escalationBody was already written to tolerate this: it filters empty sections out of the system message and has an explicit else { cacheBoundary = "" } branch, covered by testBodyOmitsContextSectionWhenEmpty. The new guard made that branch unreachable — the empty case was handled, not unsafe.

Fix

Gate on the snapshot's own resolution contract — a kernel session bound to the current owner scope, plus a freshness identity — mirroring KernelVoiceContextSnapshot.isResolved:

guard kernelContext.isResolved else {
  return .failed(Self.authorizedRealtimeToolError(code: "kernel_context_unavailable"))
}

voiceSessionContext(for:) already returns an all-empty context on owner-scope mismatch, and the prefetch only stores fields from a snapshot that passed isResolved. So the two cases the guard exists for — the .empty transport-failure sentinel and a snapshot bound to a different owner — both still fail closed. Only "resolved, but this conversation has no context material yet" now proceeds, which is what escalationBody handles.

Tests

Two regression tests on the production decision function (RealtimeHubController.VoiceSessionContext.isResolved):

  • testResolvedSnapshotWithoutContextMaterialStillAllowsEscalation — bound session + freshness identity, empty rendered/guidance/plan → escalation allowed (this is the case that broke).
  • testUnresolvedSnapshotBlocksEscalation.empty sentinel and a rendered-but-sessionless context → still fail closed.

Verification

  • xcrun swift build -c debug --package-path Desktop — clean
  • swift test --filter HubEscalationTests — 4/4 (2 new)
  • swift test --filter "RealtimeHub|RuntimeOwnerIdentity|KernelTurnRecordedProjection|FloatingControlBarState" — 164 tests, 0 failures
  • make preflight — 8/8 checks pass

Not exercised in a running app against a live model (no PTT hardware loop in this environment) — the escalation-availability decision is covered hermetically instead.

🤖 automated by hourly watchdog; opened for review, not merged.

Review in cubic

#9734 gated `ask_higher_model` on every field of the prefetched kernel
snapshot being non-empty, including the rendered context, the plan
identities, and the semantic guidance.

Rendered context is context *material*, not an availability signal.
`KernelVoiceContextSnapshot.isResolved` says so explicitly: "A valid new
conversation may render no text, but it still has a kernel session and a
deterministic freshness identity." So on the first PTT turn of a session
— nothing in the journal yet, so `renderedContext` is empty — the guard
fell through to `.failed(kernel_context_unavailable)` and the user got a
refusal instead of an answer. `RealtimeHubTools.escalationBody` already
omits each empty section on its own (`testBodyOmitsContextSectionWhenEmpty`),
so that path was unreachable, not unsafe.

Gate on the snapshot's own resolution contract instead: a kernel session
bound to the current owner scope plus a freshness identity. The `.empty`
transport-failure sentinel and an owner-scope mismatch both still fail
closed, which is what the guard was for.

Verification: xcrun swift build -c debug (clean); swift test --filter
HubEscalationTests (4/4, incl. 2 new); --filter
"RealtimeHub|RuntimeOwnerIdentity|KernelTurnRecordedProjection|FloatingControlBarState"
(164 tests, 0 failures); make preflight (8/8).

@tianmind-studio tianmind-studio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The implementation matches the existing resolution contract: the current owner scope must provide a kernel session plus freshness identity, while empty context material remains valid and is already omitted by escalationBody. The focused Swift tests and Desktop Swift CI are green, and I do not see a blocking code issue in this diff. The remaining Hygiene failure is PR metadata: this path matches INV-CHAT-1 and INV-VOICE-1, so the body must name both under Product invariants affected instead of saying none. After that citation update and a green rerun, this looks ready for maintainer review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants