Files
orca/tests/e2e/cross-version-wire/cross-version-structured-attention-frames.unit.test.ts
T
Brennan Benson 35e173a5b7 fix(native-chat): alert when a structured chat asks for approval or input mid-turn (#25766)
* fix(native-chat): alert when a structured chat asks for approval or input mid-turn

A structured Claude or Codex chat that stopped mid-turn to ask for approval or a
question raised no OS notification, no phone push and no unread dot: the host's
attention feed only fired when a turn settled. The host now announces each newly
pending prompt once, from the same feed, and owns the phone push for its own
structured sessions; desktops keep presentation only.

- host feed: a `prompt` edge per pending approval/question, opt-in on the
  existing stream; a clean settle that only restates an announced prompt is not
  re-announced to prompt-aware clients; failures always are
- host push: an in-process subscriber pushes the host's paired phones with
  labels from in-memory metadata, and withdraws a prompt's alert once answered
- one attention identity for desktop banner, phone push, dedupe and retirement;
  delivery dedupes on it instead of the per-workspace burst window
- agentSession.acknowledgeAttention routes reading a chat to its owning host,
  which retires what it pushed for that session via its dismissal record

* fix(native-chat): keep prompt alerts under their retirement identity

* Fix structured attention read and delivery lifecycle

* Allow later reads to retry failed attention retirement

* Prevent delayed prompt alerts after accepted reads

* Keep attention integration tests across process boundaries

* Check structured capability before cursorless attention reads

* Keep host retirement fixture in the Node typecheck boundary

* Keep attention test seams in the mock boundary

* Keep host notification formatting usable without Electron

* Declare agents in restored-host attention fixture

* Group session attention capability declarations

* Retire structured alerts on every read and when a remote prompt ends

- Mark read and Mark all read on a chat that was never opened, or is hidden,
  now cover what lit its row: the read boundary is the later of the transcript
  cursor and the newest edge the tab was handed, captured at the click. The
  one boundary closes the banners, retires relayed phone alerts and drives the
  host acknowledgement; an edge after the click stays live.
- An alert from a host older than journal cursors has no position, so a pane
  read retires it again, banner and relayed phone push, as before this change.
- A desktop-relayed prompt alert for a remote chat is withdrawn once that
  host's live status stops reporting attention (answered elsewhere, cancelled,
  host restart); cached status after loss of contact never settles it.
- The attention acknowledgement is agent-session.attention-ack.v1 with a
  required journal cursor and strict params; the cursorless no-op is gone.
- The dismissal store keeps a record whose origin a newer build wrote,
  dropping only that origin.
- Host reconciliation runs on restore and when a prompt leaves the pending
  set, not on every journal commit.
- Remove unused session-wide retirement (retire, retireMatching,
  retireMobileNotificationsMatching) and test-only store lookups.

* Settle relayed prompts only from host-dated status; reads default to view

- A remote chat's status and its prompt edges arrive on separate sockets, so a
  status row queued before the prompt could land after it and withdraw a
  relayed alert that was still pending. Settlement now needs a live,
  non-attention row whose host updatedAt (the journal's latest row time, never
  decreasing) is later than the prompt's host raisedAt. The owed prompt is
  cleared only once the settle call succeeds, so a failed call retries on the
  next qualifying row.
- An acknowledgement without captured reads now reads as a view. Mark read,
  Mark all read, the hover-card jump and both dashboard acks pass 'explicit';
  Activity's automatic per-turn read stays a view.

* Re-judge relayed prompt settlement after each call; dashboard watching is a view

- Settlement is one check of the current mirror, run on each status row, after
  each settle call returns, and when a prompt edge arrives. A row that lands
  while a call is out is no longer dropped, and an edge that arrives after its
  own resolution settles at once. A rejected call is retried only once the
  mirror holds a different row, so a failing call cannot loop.
- The Agent Dashboard's card click acknowledges as explicit; watching the open
  dialog acknowledges as a view, through both the in-window drawer and the
  pop-out relay, and no longer re-acks the state the click just read.

* Keep a reused read owner findable after its holders release it

A chat pane memoizes its read owner. When every holder let go in one commit and
the same owner was picked up again (React StrictMode's effect re-run in
development, or a pane remounted in one commit, as on a tab move), the release
dropped it from the registry and nothing put it back. The attention bridge then
found no owner, so a view read carried no cursor: the unread dot cleared but
the banner and phone alerts stayed. An owner now names its key again whenever
it gains an activation or a subscriber and the key is free, never over a
different live owner.

* Type the ack test's execution host ids

* Keep the turn-completion types in main's turn-completion wire module

Main moved these types into agent-session-turn-completion-wire.ts while this branch moved them into agent-session-attention-wire.ts. They now live at main's path with this branch's additions (journal cursor, prompt attention, prompt arm); the unused completion key is dropped.
2026-10-06 16:25:53 -07:00

233 lines
9.2 KiB
TypeScript

// Mixed-version coverage for the structured attention stream, with real frames on it.
//
// `agentSession.subscribeTurnCompletions` now also carries a `prompt` arm, sent only to a subscriber
// that opted in with `includePrompts`, and a new `agentSession.acknowledgeAttention` method retires
// what a host pushed its phones. Each pairing below runs one build's real RPC method over that same
// build's real attention feed, and drives a turn that asks for approval mid-turn and then settles:
//
// - current client, current host: the prompt arrives as its own frame, once;
// - older client (no opt-in), current host: only frames an older reader knows ever arrive;
// - current client, baseline host: the opt-in is ignored rather than refused, completions still come,
// and the acknowledgement method is absent exactly when its capability is.
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { StructuredAgentSessionTurnCompletionFeed } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed'
import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry'
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import { projectStructuredAgentSessionStatusState } from '../../../src/shared/structured-agent-session-projection'
import {
AGENT_SESSION_ATTENTION_ACK_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'
import {
importReleaseCheckoutModule,
materializeReleaseCheckout,
resolveBaselineReleaseRef
} from './release-checkout'
import { installableHost, structuredHostStub } from './structured-agent-session-host-fixture'
import { SESSION, WORKSPACE } from './structured-agent-session-surface-manifest'
import {
loadAgentSessionWireBuild,
WORKING_TREE,
type AgentSessionWireBuild,
type RpcReply
} from './versioned-agent-session-wire'
const SUITE_TIMEOUT_MS = 180_000
const FEED_PATH =
'src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts'
const PROJECTION_PATH = 'src/shared/structured-agent-session-projection.ts'
const ACK_METHOD = 'agentSession.acknowledgeAttention'
const LOCATION = {
executionHostId: 'local',
wslDistro: null,
workspaceId: WORKSPACE,
workspaceKind: 'git-worktree'
} as const
/** One build's own feed class and projection: the host half of the pairing. */
type FeedBuild = {
StructuredAgentSessionTurnCompletionFeed: new (deps: unknown) => {
subscribe: (subscriber: unknown) => () => void
observe: (sessionId: string) => void
}
projectStructuredAgentSessionStatusState: (
items: AgentJournalRenderItem[],
submissions: unknown[]
) => unknown
}
let current: AgentSessionWireBuild
let baseline: AgentSessionWireBuild
let currentFeed: FeedBuild
let baselineFeed: FeedBuild
async function loadFeedBuild(
load: (path: string) => Promise<Record<string, unknown>>
): Promise<FeedBuild> {
const [feed, projection] = await Promise.all([load(FEED_PATH), load(PROJECTION_PATH)])
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each build exports both members by these names; a missing one fails the first pairing.
return { ...feed, ...projection } as unknown as FeedBuild
}
beforeAll(async () => {
const ref = resolveBaselineReleaseRef()
current = await loadAgentSessionWireBuild(WORKING_TREE)
baseline = await loadAgentSessionWireBuild(ref)
const checkout = await materializeReleaseCheckout(ref)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: current source's own exports, typed loosely to match the release build's.
currentFeed = {
StructuredAgentSessionTurnCompletionFeed,
projectStructuredAgentSessionStatusState
} as unknown as FeedBuild
baselineFeed = await loadFeedBuild((path) => importReleaseCheckoutModule(checkout, path))
}, SUITE_TIMEOUT_MS)
const USER: AgentJournalRenderItem = {
itemId: 'user-1',
revision: 0,
sequence: 1,
observedAt: 1,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'migrate' }] }
}
function turnRow(state: 'running' | 'completed'): AgentJournalRenderItem {
return {
itemId: 'codex:turn:turn-1',
revision: state === 'running' ? 1 : 2,
sequence: 2,
observedAt: 2,
body: {
kind: 'turn',
turnId: 'turn-1',
state,
...(state === 'completed' ? { outcome: 'success' } : {})
}
}
}
function approval(state: 'pending' | 'resolved'): AgentJournalRenderItem {
return {
itemId: 'approval-1',
revision: state === 'pending' ? 1 : 2,
sequence: 3,
observedAt: 3,
body: {
kind: 'approval',
title: 'Run migration?',
detail: null,
options: [{ id: 'yes', label: 'Allow' }],
resolution: { state, selectedOptionId: null, resolvedBy: null, resolvedAt: null }
}
}
}
/** The journal states a turn passes through: running, asking mid-turn, answered, settled. */
const JOURNEY: AgentJournalRenderItem[][] = [
[USER, turnRow('running')],
[USER, turnRow('running'), approval('pending')],
[USER, turnRow('running'), approval('resolved')],
[USER, turnRow('completed'), approval('resolved')]
]
/** Runs the journey through `host`'s feed behind `rpc`'s dispatcher, and returns every frame the
* subscriber that sent `params` received. */
async function framesFor(
rpc: AgentSessionWireBuild,
host: FeedBuild,
params: unknown
): Promise<unknown[]> {
let items: AgentJournalRenderItem[] = []
let sequence = 0
const sessions = new Map([
[
SESSION,
{
journal: { cursor: () => ({ epoch: 'epoch-1', sequence }) },
params: { location: LOCATION }
}
]
])
const feed = new host.StructuredAgentSessionTurnCompletionFeed({
sessions,
now: () => 10,
readStatusState: () => host.projectStructuredAgentSessionStatusState(items, [])
})
const hostCalls = structuredHostStub(SESSION, WORKSPACE)
hostCalls.subscribeTurnCompletions = vi.fn((subscriber: unknown) => feed.subscribe(subscriber))
await rpc.installStructuredHost(installableHost(hostCalls))
const replies: RpcReply[] = []
await rpc.createDispatcher(runtimeStub()).dispatchStreaming(
{
id: 'attention',
authToken: 'cross-version-token',
method: 'agentSession.subscribeTurnCompletions',
params
},
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every frame the dispatcher writes is a serialized RpcReply; a drifted shape fails the assertions below.
(raw) => replies.push(JSON.parse(raw) as RpcReply),
{ clientKind: 'runtime', clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] }
)
expect(
replies.filter((reply) => !reply.ok),
`${rpc.label}: subscription refused ${JSON.stringify(replies)}`
).toEqual([])
for (const next of JOURNEY) {
items = next
sequence += 1
feed.observe(SESSION)
}
return replies.map((reply) => reply.result)
}
function runtimeStub(): unknown {
const subscriptions = new RuntimeSubscriptionRegistry()
return {
getRuntimeId: () => 'runtime-1',
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
ensureStructuredAgentSessionHost: async () => undefined,
registerSubscriptionCleanup: subscriptions.register.bind(subscriptions),
registerOwnedSubscriptionCleanup: subscriptions.registerOwned.bind(subscriptions),
cleanupSubscription: subscriptions.cleanup.bind(subscriptions),
cleanupSubscriptionsByPrefix: subscriptions.cleanupByPrefix.bind(subscriptions)
}
}
const frameTypes = (frames: unknown[]): unknown[] =>
frames.map((frame) =>
frame && typeof frame === 'object' && 'type' in frame ? frame.type : frame
)
describe('structured attention frames across versions', () => {
it('current client, current host: the mid-turn prompt is its own frame, then the settle', async () => {
const frames = await framesFor(current, currentFeed, { includePrompts: true })
expect(frameTypes(frames)).toEqual(['prompt', 'completion'])
expect(frames[0]).toMatchObject({ prompt: { sessionId: SESSION, promptId: 'approval-1' } })
})
it('older client against the current host: only the frames an older reader already knows', async () => {
// A client that predates the opt-in sends none; the arm it could not read must never reach it.
const frames = await framesFor(current, currentFeed, {})
expect(frameTypes(frames)).toEqual(['completion'])
})
it('current client against the baseline host: the opt-in is ignored, not refused', async () => {
const frames = await framesFor(baseline, baselineFeed, { includePrompts: true })
// Whatever the baseline host sends, the settle still arrives and nothing it sends is refused.
expect(frameTypes(frames)).toContain('completion')
for (const frame of frames) {
expect(['completion', 'prompt']).toContain(frameTypes([frame])[0])
}
})
it('the acknowledgement method exists exactly where its capability is advertised', () => {
for (const build of [current, baseline]) {
expect(build.methodNames.includes(ACK_METHOD), build.label).toBe(
build.capabilities.includes(AGENT_SESSION_ATTENTION_ACK_RUNTIME_CAPABILITY)
)
}
expect(current.capabilities).toContain(AGENT_SESSION_ATTENTION_ACK_RUNTIME_CAPABILITY)
})
})