fix(chat): show device names on answer receipts (#37776)

* fix(chat): show device names on answer receipts

* fix(chat): resolve receipt names across local and remote hosts

* test(chat): type receipt boundary test dependencies

* fix(chat): keep receipt labels within history frame budgets

* test(chat): return complete compatibility attach replies
This commit is contained in:
Kelvin Amoaba
2026-11-03 10:09:26 +00:00
committed by GitHub
parent cd2f282bc3
commit ee532e571c
26 changed files with 744 additions and 113 deletions
+4 -1
View File
@@ -3062,7 +3062,10 @@
"resolved": "Resolved",
"cancelled": "Cancelled",
"resolver": "Answered on {{device}}",
"cancelledBy": "Cancelled on {{device}}"
"cancelledBy": "Cancelled on {{device}}",
"thisPhone": "this phone",
"computer": "the computer",
"otherDevice": "another device"
},
"subagentGroup": {
"stateCompleted": "completed",
@@ -2,6 +2,7 @@ import { StyleSheet, Text, View } from 'react-native'
import {
nativeChatReceiptAnswerHeading,
nativeChatReceiptAnswers,
nativeChatReceiptDevice,
nativeChatReceiptSubject,
type NativeChatResolvedPrompt
} from '../../../../src/shared/native-chat/native-chat-resolution-receipt'
@@ -40,7 +41,12 @@ export function MobileNativeChatResolutionReceipt({
) : null
}
const { resolution } = body
const copy = nativeChatReceiptCopy(t, resolution.resolvedBy ?? undefined)
const device = nativeChatReceiptDevice(resolution, {
current: t('chat.receipt.thisPhone', 'this phone'),
computer: t('chat.receipt.computer', 'the computer'),
unknown: t('chat.receipt.otherDevice', 'another device')
})
const copy = nativeChatReceiptCopy(t, device)
const cancelled = resolution.state === 'cancelled'
const answers = nativeChatReceiptAnswers(body)
const outcome = [
@@ -84,65 +84,77 @@ describe('MobileNativeChatMessage', () => {
const words = (node: ReactTestInstance): string[] =>
node.children.flatMap((child) => (typeof child === 'string' ? [child] : words(child)))
it('keeps answered receipts beside Started worker through the production structured hook', async () => {
const items = [
{
itemId: 'start',
revision: 1,
sequence: 42,
observedAt: 1,
body: {
kind: 'agent-start' as const,
dispatchId: 'dispatch',
task: 'Worker assignment',
worker: {
party: { address: 'term_worker', terminalHandle: 'term_worker', orcaSessionId: null },
name: 'Worker'
it.each([
[undefined, 'the computer'],
[{ kind: 'current' }, 'this phone'],
[{ kind: 'named', name: 'Work Mac' }, 'Work Mac']
] satisfies [NativeChatResolvedPrompt['resolution']['resolvedByDevice'], string][])(
'keeps answered receipts with device %j through the production structured hook',
async (resolvedByDevice, device) => {
const items = [
{
itemId: 'start',
revision: 1,
sequence: 42,
observedAt: 1,
body: {
kind: 'agent-start' as const,
dispatchId: 'dispatch',
task: 'Worker assignment',
worker: {
party: { address: 'term_worker', terminalHandle: 'term_worker', orcaSessionId: null },
name: 'Worker'
}
}
}
},
...ANSWERED_PROMPT_ITEMS
]
const client = stubRpcClient({
sendRequest: async () => ok({}),
subscribe: (method, _params, onData) => {
if (method === 'agentSession.subscribe') {
onData(snapshotEvent({ items }))
}
return () => {}
}
})
const onSendError = vi.fn()
function Transcript() {
const structured = useMobileStructuredAgentSession({
client,
pairedHostClient: client,
sessionId: 'session-1',
agent: 'claude',
enabled: true,
connected: true,
hostSupport: null,
onSendError
})
return structured.session.messages.map((message) =>
createElement(MobileNativeChatMessage, {
key: message.id,
message,
receipt: structured.receipts.get(message.id)
},
...ANSWERED_PROMPT_ITEMS.map((item) => {
const { body } = item
return body.kind === 'approval' || body.kind === 'question'
? { ...item, body: { ...body, resolution: { ...body.resolution, resolvedByDevice } } }
: item
})
]
const client = stubRpcClient({
sendRequest: async () => ok({}),
subscribe: (method, _params, onData) => {
if (method === 'agentSession.subscribe') {
onData(snapshotEvent({ items }))
}
return () => {}
}
})
const onSendError = vi.fn()
function Transcript() {
const structured = useMobileStructuredAgentSession({
client,
pairedHostClient: client,
sessionId: 'session-1',
agent: 'claude',
enabled: true,
connected: true,
hostSupport: null,
onSendError
})
return structured.session.messages.map((message) =>
createElement(MobileNativeChatMessage, {
key: message.id,
message,
receipt: structured.receipts.get(message.id)
})
)
}
await act(async () => {
renderer = create(createElement(Transcript))
})
const drawn = words(renderer!.root).join('')
expect(drawn).toContain('Started worker')
expect(drawn).toContain(
`Asked: Do you prefer tea or coffee?CoffeeResolved · Answered on ${device}`
)
expect(drawn).toContain(`AllowResolved · Answered on ${device}`)
expect(markdownIn(renderer!.root)[0]).toBe('Worker assignment')
}
await act(async () => {
renderer = create(createElement(Transcript))
})
const drawn = words(renderer!.root).join('')
expect(drawn).toContain('Started worker')
expect(drawn).toContain(
'Asked: Do you prefer tea or coffee?CoffeeResolved · Answered on desktop-renderer'
)
expect(drawn).toContain('AllowResolved · Answered on desktop-renderer')
expect(markdownIn(renderer!.root)[0]).toBe('Worker assignment')
})
)
it.each([
['en', 'agent-start', 'Worker assignment', 'Started worker'],
+60
View File
@@ -22,6 +22,12 @@ vi.mock('electron', () => ({
}
}))
import {
clearStructuredHostStub,
hostCalls,
installStructuredHostStub,
SESSION
} from '../runtime/rpc/methods/structured-agent-session-rpc.test-fixture'
import { registerRuntimeHandlers } from './runtime'
import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline'
import { PROJECT_SECTION_PARITY_SERVED } from '../../shared/project-section-parity.test-fixture'
@@ -49,6 +55,60 @@ describe('registerRuntimeHandlers', () => {
fromWebContentsMock.mockReset()
})
it.each(['desktop-renderer', 'trusted-local:runtime'])(
'identifies an answer by %s as this computer through desktop IPC',
async (resolvedBy) => {
installStructuredHostStub()
try {
hostCalls.history.mockReturnValue({
ok: true,
page: {
submissions: [],
removedItemIds: [],
items: [
{
itemId: 'question-1',
sequence: 1,
revision: 2,
observedAt: 10,
body: {
kind: 'question',
question: 'Which color?',
options: [],
resolution: {
state: 'resolved',
selectedOptionId: null,
resolvedBy,
resolvedAt: 10
}
}
}
]
}
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this IPC read uses only identity/name methods; the installed host supplies journal reads.
registerRuntimeHandlers({
getRuntimeId: () => 'runtime-1',
readMachineName: () => 'Work Mac',
readPairedDeviceName: () => null
} as never)
const handler = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call')![1]
const response = await handler(runtimeCallEvent(), {
method: 'agentSession.history',
params: { sessionId: SESSION, direction: 'tail' }
})
expect(response).toMatchObject({
ok: true,
result: {
page: { items: [{ body: { resolution: { resolvedByDevice: { kind: 'current' } } } }] }
}
})
} finally {
clearStructuredHostStub()
}
}
)
it('routes sync requests through the authoritative browser window id', () => {
const runtime = {
syncWindowGraph: vi.fn().mockReturnValue({ graphStatus: 'ready' }),
@@ -34,6 +34,24 @@ export function historyEntryBytes(
return Buffer.byteLength(JSON.stringify(item), 'utf8') + (submissionBytes.get(item.itemId) ?? 0)
}
/** Count removal IDs and submissions alongside the exact items sent to a reader. */
export function historyContentBytes(
items: readonly HistoryItemForm[],
removedItemIds: readonly string[],
submissionBytes: ReadonlyMap<string, number>
): number {
return (
items.reduce(
(total, { item, bytes }) => total + bytes + (submissionBytes.get(item.itemId) ?? 0),
0
) +
removedItemIds.reduce(
(total, itemId) => total + Buffer.byteLength(JSON.stringify(itemId), 'utf8') + 1,
0
)
)
}
// Snapshot arrays change with submissions, so cache serialization once per paged snapshot.
const bytesBySubmissions = new WeakMap<
readonly AgentJournalSubmission[],
@@ -26,6 +26,7 @@ import { projectJournalBatch } from './agent-session-journal-batch'
import { CLIENT_HISTORY_READER } from './agent-session-item-wire-form'
import {
boundHistoryItemsByBytes,
historyContentBytes,
type HistoryReader,
measureHistoryItem,
newestWholeSequenceGroups,
@@ -214,19 +215,6 @@ function readForward(
return historyReset(snapshot, since.reset, reader)
}
const submissionBytes = submissionBytesByItemId(snapshot.submissions)
// Count submissions and removal IDs too: legacy tombstones can outweigh all item content.
const pageContentBytes = (
items: readonly ReturnType<HistoryReader['form']>[],
removedItemIds: readonly string[]
): number =>
items.reduce(
(total, { item, bytes }) => total + bytes + (submissionBytes.get(item.itemId) ?? 0),
0
) +
removedItemIds.reduce(
(total, itemId) => total + Buffer.byteLength(JSON.stringify(itemId), 'utf8') + 1,
0
)
// Shrink the row window; clipping items after advancing the cursor would permanently lose revisions.
let rows = since.rows.slice(0, limit)
const project = (window: typeof rows) => {
@@ -244,7 +232,11 @@ function readForward(
if (!projected.ok) {
return historyReset(snapshot, projected.reset, reader)
}
let contentBytes = pageContentBytes(projected.batch.items, projected.batch.removedItemIds)
let contentBytes = historyContentBytes(
projected.batch.items,
projected.batch.removedItemIds,
submissionBytes
)
while (rows.length > 1 && contentBytes > reader.budgetBytes) {
rows = rows.slice(0, Math.ceil(rows.length / 2))
const shrunk = project(rows)
@@ -252,7 +244,11 @@ function readForward(
return historyReset(snapshot, shrunk.reset, reader)
}
projected = shrunk
contentBytes = pageContentBytes(projected.batch.items, projected.batch.removedItemIds)
contentBytes = historyContentBytes(
projected.batch.items,
projected.batch.removedItemIds,
submissionBytes
)
}
let items = projected.batch.items
if (contentBytes > reader.budgetBytes) {
@@ -262,7 +258,7 @@ function readForward(
? measureHistoryItem(oversizedHistoryItem(formed.item))
: formed
})
contentBytes = pageContentBytes(items, projected.batch.removedItemIds)
contentBytes = historyContentBytes(items, projected.batch.removedItemIds, submissionBytes)
}
if (contentBytes > reader.budgetBytes) {
// Unsplittable removal IDs require whole-state reset; resume at the live cursor past the oversized row.
+4
View File
@@ -190,7 +190,10 @@ async function startOrcadRuntime(
const { loadAgentSessionClaimSigner } =
await import('../runtime/agent-session/ownership/agent-session-claim-identity')
let runtimeRpc: InstanceType<typeof OrcaRuntimeRpcServer> | null = null
const runtime = new OrcaRuntimeService(profileStore, stats, {
getPairedDeviceName: (deviceId) =>
runtimeRpc?.getDeviceRegistry()?.getDevice(deviceId)?.name ?? null,
// Persistent signatures let restarted runtimes adopt PTYs owned before restart.
agentSessionClaimSigner: loadAgentSessionClaimSigner(runtimeUserDataPath, runtimeUserDataPath),
getAdditionalAiVaultCodexHomePaths: () => [
@@ -302,6 +305,7 @@ async function startOrcadRuntime(
webClientRoot: resolveOrcadWebClientRoot(options.webClientRoot),
...orcadWsPortOptions(options.port)
})
runtimeRpc = rpc
runtime.setAgentSenderPeerKey(() => rpc.getE2EEPublicKey())
// Stop RPC before cleanup can tear down writers and runtime state.
registerCleanup(() => rpc.stop())
+32
View File
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { ProfilePreferences } from '../persistence/loading-store/profile-preferences'
import type { RuntimeConstructionDependencies } from '../runtime/construction/runtime-construction-dependencies'
import type { GlobalSettings } from '../../shared/global-settings-types'
import { DeviceRegistry } from '../runtime/device-registry'
import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller'
@@ -11,6 +12,11 @@ import { createPushHostKeypair } from '../runtime/push/push-host-challenge-fixtu
import { acquireProfileStateMaintenance } from '../persistence/profile-state/profile-state-access'
import { profileStateAccessPaths } from '../persistence/profile-state/profile-state-access-owner'
const pairingReader = vi.hoisted(() => {
const reader: { current?: RuntimeConstructionDependencies['getPairedDeviceName'] } = {}
return reader
})
const state = vi.hoisted(() => ({
root: '',
controller: null as RuntimeMobileNotificationController | null,
@@ -147,6 +153,9 @@ vi.mock('../server/serve-readiness', () => ({
}))
vi.mock('../runtime/orca-runtime', () => ({
OrcaRuntimeService: class {
constructor(_store: unknown, _stats: unknown, deps: RuntimeConstructionDependencies) {
pairingReader.current = deps.getPairedDeviceName
}
readonly sidebarFacts = null
readonly agentTransitions = { seedRestoredRows() {} }
attachAgentStatusStore() {
@@ -244,6 +253,29 @@ it('refuses recovery overlap before initializing the browser provider or runtime
}
})
it('reads live pairing names from the standalone RPC registry', async () => {
state.root = mkdtempSync(join(tmpdir(), 'orca-headless-names-'))
state.controller = new RuntimeMobileNotificationController()
state.registry = new DeviceRegistry(state.root)
const { startOrcad } = await import('./orcad-entry')
const host = await startOrcad({ noPairing: true, json: true })
try {
const desktop = state.registry.addDevice('Work Mac', 'runtime')
const [phone] = state.registry.upsertDelegatedMobileDevices(desktop, [
{ phoneKey: 'phone-1', name: 'My iPhone' }
])
expect(pairingReader.current?.(desktop.deviceId)).toBe('Work Mac')
expect(pairingReader.current?.(phone!.deviceId)).toBe('My iPhone')
state.registry.upsertDelegatedMobileDevices(desktop, [
{ phoneKey: 'phone-1', name: 'Renamed iPhone' }
])
expect(pairingReader.current?.(phone!.deviceId)).toBe('Renamed iPhone')
expect(pairingReader.current?.('unpaired')).toBeNull()
} finally {
await host.stop()
}
})
it('starts push after RPC identity is available and stops dispatch on shutdown', async () => {
state.root = mkdtempSync(join(tmpdir(), 'orca-headless-push-'))
state.controller = new RuntimeMobileNotificationController()
@@ -75,11 +75,6 @@ const DECLARED_HOST_DIFFERENCES: readonly DeclaredDifference[] = [
missingOn: 'orcad',
reason: 'Pairs with attestAgentHookCompatibilityAuthority.'
},
{
wiring: 'ctor:getPairedDeviceName',
missingOn: 'orcad',
reason: 'Unwired gap (follow-up): client-hosted browser rows show no paired-device name.'
},
{
wiring: 'ctor:orchestrationEnvironmentTransport',
missingOn: 'orcad',
@@ -175,6 +175,10 @@ export abstract class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithPtyControl
return this.machineName.read()
}
readPairedDeviceName(deviceId: string): string | null {
return this.getPairedDeviceNameFn(deviceId)
}
/** Bound machine-name lookup; slow lookups temporarily publish hostname, then their result on the next read. */
machineNameReady(): Promise<void> {
return this.machineName.readyWithin(MACHINE_NAME_PUBLISH_WAIT_MS)
@@ -20,6 +20,7 @@ import {
} from './structured-agent-session-create'
import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { projectReceiptDeviceAttach } from './structured-agent-session-receipt-devices'
import {
structuredAgentSessionClosedBeforeCreate,
structuredAgentSessionClosedBeforeCreateRefusal
@@ -52,7 +53,7 @@ export async function attachClientSuppliedLocation(
ctx: RpcContext
): Promise<unknown> {
const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx)
return host.attach(callerFor(ctx), attachParams)
return projectReceiptDeviceAttach(await host.attach(callerFor(ctx), attachParams), ctx)
}
export async function handleStructuredAgentSessionCreate(
@@ -111,15 +112,18 @@ export async function handleStructuredAgentSessionCreate(
) {
return { ok: false, refusal: structuredAgentSessionClosedBeforeCreateRefusal() }
}
return commitStructuredAgentSessionCreate({
runtime: ctx.runtime,
caller: callerFor(ctx),
prepared,
activate: true,
placedByWindow: ctx.caller?.kind === 'desktop',
...(capture ? { capture } : {}),
...('tabPlacement' in params && params.tabPlacement
? { tabPlacement: params.tabPlacement }
: {})
})
return projectReceiptDeviceAttach(
await commitStructuredAgentSessionCreate({
runtime: ctx.runtime,
caller: callerFor(ctx),
prepared,
activate: true,
placedByWindow: ctx.caller?.kind === 'desktop',
...(capture ? { capture } : {}),
...('tabPlacement' in params && params.tabPlacement
? { tabPlacement: params.tabPlacement }
: {})
}),
ctx
)
}
@@ -6,6 +6,7 @@ import { shortenedAgentJournalItem } from '../../../native-chat/agent-session-wi
import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../../shared/remote-runtime/remote-runtime-memory-limits'
import { defineMethod } from '../core'
import { requireInstalledStructuredHost } from './structured-agent-session-gate'
import { projectReceiptDeviceItem } from './structured-agent-session-receipt-devices'
/** The item's share of one reply, with room for the envelope beside it. */
const ITEM_REPLY_MAX_BYTES = REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES - 64 * 1024
@@ -24,7 +25,9 @@ export const STRUCTURED_AGENT_SESSION_ITEM_METHODS = [
return { item: null }
}
// Null only for an item whose identifiers alone exceed a reply; the client keeps its head.
return { item: shortenedAgentJournalItem(item, ITEM_REPLY_MAX_BYTES) }
return {
item: shortenedAgentJournalItem(projectReceiptDeviceItem(item, ctx), ITEM_REPLY_MAX_BYTES)
}
}
})
]
@@ -0,0 +1,159 @@
import '../unused-default-rpc-methods.test-fixture'
import { afterEach, beforeEach, expect, it } from 'vitest'
import type { AgentSessionSubscribeInput } from '../../../native-chat/agent-session-wire/structured-agent-session-subscribers'
import {
call,
clearStructuredHostStub,
hostCalls,
installStructuredHostStub,
SESSION,
STRUCTURED_CLIENT
} from './structured-agent-session-rpc.test-fixture'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session/journal/agent-session-journal-types'
const DEVICE_ID = '5d857d9e-ec3f-4762-b7f4-69691f580707'
const receipt: AgentJournalRenderItem = {
itemId: 'question-1',
sequence: 1,
revision: 2,
observedAt: 10,
body: {
kind: 'question',
question: 'Which color?',
options: [{ id: 'red', label: 'Red' }],
resolution: {
state: 'resolved',
selectedOptionId: 'red',
resolvedBy: DEVICE_ID,
resolvedAt: 10
}
}
}
beforeEach(installStructuredHostStub)
afterEach(clearStructuredHostStub)
it('projects paired names onto historical receipts without changing the stored row', async () => {
hostCalls.history.mockReturnValue({
ok: true,
page: { items: [receipt], submissions: [], removedItemIds: [] }
})
const response = await call(
'agentSession.history',
{ sessionId: SESSION, direction: 'tail' },
STRUCTURED_CLIENT,
{ readPairedDeviceName: (id: string) => (id === DEVICE_ID ? "Kelvin's iPhone" : null) }
)
expect(response).toMatchObject({
ok: true,
result: {
page: {
items: [
{
body: {
resolution: {
resolvedBy: DEVICE_ID,
resolvedByDevice: { kind: 'named', name: "Kelvin's iPhone" }
}
}
}
]
}
}
})
expect(receipt.body).toMatchObject({
resolution: { resolvedBy: DEVICE_ID }
})
expect(receipt.body).not.toHaveProperty('resolution.resolvedByDevice')
})
it.each(['snapshot', 'batch', 'reset'] as const)(
'identifies the reader on a live %s receipt',
async (type) => {
hostCalls.subscribe.mockImplementation((input: AgentSessionSubscribeInput) => {
const base = { sessionId: SESSION, fence: 1 }
if (type === 'batch') {
input.emit({
...base,
type,
batch: {
cursor: { epoch: 'a', sequence: 1 },
items: [receipt],
removedItemIds: [],
submissions: []
}
})
} else {
const page = {
sessionId: SESSION,
epoch: 'a',
direction: 'tail' as const,
items: [receipt],
removedItemIds: [],
submissions: [],
window: { oldest: null, newest: null, nextCursor: { epoch: 'a', sequence: 1 } },
hasOlder: false,
hasNewer: false
}
input.emit(
type === 'snapshot'
? { ...base, type, page }
: { ...base, type, page, reset: 'epoch_changed' }
)
}
return () => {}
})
const response = await call(
'agentSession.subscribe',
{ sessionId: SESSION },
{ ...STRUCTURED_CLIENT, clientId: DEVICE_ID }
)
const container = type === 'batch' ? 'batch' : 'page'
expect(response).toMatchObject({
ok: true,
result: {
[container]: {
items: [{ body: { resolution: { resolvedByDevice: { kind: 'current' } } } }]
}
}
})
expect(receipt.body).not.toHaveProperty('resolution.resolvedByDevice')
}
)
it.each(['desktop-renderer', 'trusted-local:runtime'])(
'names a historical local answer by %s for a paired reader',
async (resolvedBy) => {
if (receipt.body.kind !== 'question') {
throw new Error('expected question')
}
const item = {
...receipt,
body: { ...receipt.body, resolution: { ...receipt.body.resolution, resolvedBy } }
}
hostCalls.history.mockReturnValue({
ok: true,
page: { items: [item], submissions: [], removedItemIds: [] }
})
const response = await call(
'agentSession.history',
{ sessionId: SESSION, direction: 'tail' },
{ ...STRUCTURED_CLIENT, clientId: DEVICE_ID },
{ readMachineName: () => 'Work Mac' }
)
expect(response).toMatchObject({
ok: true,
result: {
page: {
items: [
{
body: {
resolution: { resolvedByDevice: { kind: 'named', name: 'Work Mac' } }
}
}
]
}
}
})
}
)
@@ -0,0 +1,114 @@
import { isNativeChatLocalResolver } from '../../../../shared/native-chat/native-chat-resolution-receipt'
import type {
AgentJournalRenderItem,
AgentJournalResolution
} from '../../../../shared/agent-session/journal/agent-session-journal-types'
import type {
AgentSessionAttachResult,
AgentSessionHistoryPage,
AgentSessionHistoryResult,
AgentSessionMutationResult,
AgentSessionSubscribeEvent
} from '../../../../shared/agent-session/agent-session-wire'
import {
BOUNDED_FRAME_CONTENT_BUDGET_BYTES,
HISTORY_PAGE_CONTENT_BUDGET_BYTES,
historyContentBytes,
measureHistoryItem,
submissionBytesByItemId
} from '../../../native-chat/agent-session-wire/agent-session-history-page-bounds'
import type { RpcContext } from '../core'
import { structuredCallerFor } from './structured-agent-session-gate'
type ReceiptReader = Pick<RpcContext, 'runtime' | 'caller' | 'clientId' | 'clientKind'>
function resolverDevice(
resolvedBy: string,
ctx: ReceiptReader
): AgentJournalResolution['resolvedByDevice'] {
const caller = structuredCallerFor(ctx).callerKey
const local = isNativeChatLocalResolver(resolvedBy)
if (resolvedBy === caller || (local && isNativeChatLocalResolver(caller))) {
return { kind: 'current' }
}
const name = local ? ctx.runtime.readMachineName() : ctx.runtime.readPairedDeviceName(resolvedBy)
return name?.trim() ? { kind: 'named', name } : undefined
}
/** Project every read afresh so historical receipts follow current pairing names. */
export function projectReceiptDeviceItem(
item: AgentJournalRenderItem,
ctx: ReceiptReader
): AgentJournalRenderItem {
const { body } = item
if ((body.kind !== 'question' && body.kind !== 'approval') || !body.resolution.resolvedBy) {
return item
}
const resolvedByDevice = resolverDevice(body.resolution.resolvedBy, ctx)
return { ...item, body: { ...body, resolution: { ...body.resolution, resolvedByDevice } } }
}
function projectItems(
content: Pick<AgentSessionHistoryPage, 'items' | 'submissions' | 'removedItemIds'>,
ctx: ReceiptReader,
budgetBytes: number
): AgentJournalRenderItem[] {
const items = content.items.map((item) => projectReceiptDeviceItem(item, ctx))
if (items.every((item, index) => item === content.items[index])) {
return content.items
}
const bytes = historyContentBytes(
items.map(measureHistoryItem),
content.removedItemIds,
submissionBytesByItemId(content.submissions)
)
// Display metadata must not consume the envelope reserve or displace existing history.
return bytes <= budgetBytes ? items : content.items
}
function projectPage(
page: AgentSessionHistoryPage,
ctx: ReceiptReader,
budgetBytes = HISTORY_PAGE_CONTENT_BUDGET_BYTES
): AgentSessionHistoryPage {
return { ...page, items: projectItems(page, ctx, budgetBytes) }
}
export function projectReceiptDeviceHistory(
result: AgentSessionHistoryResult,
ctx: ReceiptReader
): AgentSessionHistoryResult {
return { ...result, page: projectPage(result.page, ctx) }
}
export function projectReceiptDeviceAttach(
result: AgentSessionMutationResult<AgentSessionAttachResult>,
ctx: ReceiptReader
): AgentSessionMutationResult<AgentSessionAttachResult> {
return result.ok
? { ...result, value: { ...result.value, page: projectPage(result.value.page, ctx) } }
: result
}
export function projectReceiptDeviceEvent(
event: AgentSessionSubscribeEvent,
ctx: ReceiptReader
): AgentSessionSubscribeEvent {
const budgetBytes =
ctx.clientKind === 'mobile'
? BOUNDED_FRAME_CONTENT_BUDGET_BYTES
: HISTORY_PAGE_CONTENT_BUDGET_BYTES
if (event.type === 'batch') {
return {
...event,
batch: {
...event.batch,
items: projectItems(event.batch, ctx, budgetBytes)
}
}
}
if (event.type === 'snapshot' || event.type === 'reset') {
return { ...event, page: projectPage(event.page, ctx, budgetBytes) }
}
return event
}
@@ -0,0 +1,102 @@
import '../unused-default-rpc-methods.test-fixture'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../shared/agent-session/journal/agent-session-journal-types'
import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session/agent-session-wire'
import { codexProviderHandle } from '../../../../shared/agent-session/lifecycle/agent-session-provider-handle-encoding'
import { MOBILE_BRIDGE_MAX_FRAME_BYTES } from '../../../../shared/mobile-bridge-frame-bytes'
import {
AgentSessionSubscribers,
type AgentSessionSubscribeInput
} from '../../../native-chat/agent-session-wire/structured-agent-session-subscribers'
import {
fixture,
journals
} from '../../../native-chat/agent-session-wire/structured-agent-session-subscriber-contract-test-fixture'
import {
clearStructuredHostStub,
hostCalls,
installStructuredHostStub,
openStream,
SESSION,
STRUCTURED_MOBILE_CLIENT
} from './structured-agent-session-rpc.test-fixture'
beforeEach(installStructuredHostStub)
afterEach(clearStructuredHostStub)
it.each(['snapshot', 'reset', 'batch'] as const)(
'keeps near-limit %s delivery readable when receipt names do not fit',
async (type) => {
const journal = await journals.open({
identity: {
sessionId: SESSION,
workspaceId: 'workspace-1',
hostId: 'local',
agent: 'codex',
providerHandle: codexProviderHandle('thread-1')
},
stateDirectory: join(fixture.root, 'receipts')
})
const start = journal.cursor()
for (let ordinal = 1; ordinal <= 200; ordinal++) {
await journal.appendItem(
{ provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal },
{
kind: 'approval',
title: `Approval ${ordinal}`,
detail: 'x'.repeat(2500),
options: [{ id: 'yes', label: 'Allow' }],
resolution: {
state: 'resolved',
selectedOptionId: 'yes',
resolvedBy: 'desktop-renderer',
resolvedAt: 10
}
},
{ fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
}
const base: AgentSessionSubscribeEvent[] = []
const subscribers = new AgentSessionSubscribers()
hostCalls.subscribe.mockImplementation((input: AgentSessionSubscribeInput) =>
subscribers.open({
...input,
journal,
fence: 7,
emit: (event) => {
base.push(event)
input.emit(event)
}
})
)
const cursor = type === 'reset' ? { epoch: 'old', sequence: 0 } : start
const responses = await openStream(
'agentSession.subscribe',
{ sessionId: SESSION, ...(type === 'snapshot' ? {} : { cursor }) },
{ ...STRUCTURED_MOBILE_CLIENT, clientId: 'reader-phone' },
{ readMachineName: () => '界'.repeat(255) }
)
expect(base[0]?.type).toBe(type)
expect(Buffer.byteLength(JSON.stringify(base[0]), 'utf8')).toBeGreaterThan(540 * 1024)
expect(responses).toHaveLength(base.length)
for (const [index, response] of responses.entries()) {
expect(response).toMatchObject({ ok: true, result: base[index] })
if (!response.ok) {
throw new Error('expected receipt delivery')
}
const frame = {
v: 1,
type: 'event',
id: 'receipt-stream',
seq: 2 ** 31,
payload: response.result
}
expect(Buffer.byteLength(JSON.stringify(frame), 'utf8')).toBeLessThanOrEqual(
MOBILE_BRIDGE_MAX_FRAME_BYTES
)
}
expect(journal.snapshot().items).toHaveLength(200)
expect(JSON.stringify(journal.snapshot().items)).not.toContain('resolvedByDevice')
}
)
@@ -261,6 +261,7 @@ export function dispatcher(
})
const runtime = {
getRuntimeId: () => 'runtime-1',
readPairedDeviceName: () => null,
getClientSettings: () => ({ nativeChatProviders: EVERY_AGENT_CHAT }),
// The host reads saved settings privately; follow the client settings each test chose.
requireStore: () => ({ getSettings: () => runtime.getClientSettings() }),
@@ -72,6 +72,10 @@ import { getStructuredAgentSessionHost } from '../../../native-chat/agent-sessio
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { refusingStructuredAgentSessionCreatesClosedFirst } from './structured-agent-session-close-before-create'
import {
projectReceiptDeviceEvent,
projectReceiptDeviceHistory
} from './structured-agent-session-receipt-devices'
export const STRUCTURED_AGENT_SESSION_METHODS = [
defineMethod({
@@ -206,20 +210,23 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
params: HistoryParams,
handler: async (params, ctx) => {
const host = await requireInstalledHost(ctx, params.sessionId)
return projectTurnItemHistory(
projectQueuedImageCardHistory(
projectBackgroundTaskHistory(
await host.history(
params,
undefined,
clientSupportsSnapshotCatchUp(ctx) && clientReadsCurrentRequests(ctx)
return projectReceiptDeviceHistory(
projectTurnItemHistory(
projectQueuedImageCardHistory(
projectBackgroundTaskHistory(
await host.history(
params,
undefined,
clientSupportsSnapshotCatchUp(ctx) && clientReadsCurrentRequests(ctx)
),
ctx
),
ctx
),
ctx
ctx,
host.sessionAgent(params.sessionId)
),
ctx,
host.sessionAgent(params.sessionId)
ctx
)
}
}),
@@ -246,10 +253,13 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
projectQueuedImageCardPublication(publication, ctx),
emit: (event) =>
emit(
projectTurnItemEvent(
projectBackgroundTaskEvent(event, ctx),
ctx,
host.sessionAgent(params.sessionId)
projectReceiptDeviceEvent(
projectTurnItemEvent(
projectBackgroundTaskEvent(event, ctx),
ctx,
host.sessionAgent(params.sessionId)
),
ctx
)
),
...(params.cursor ? { cursor: params.cursor } : {}),
@@ -820,7 +820,7 @@ import type { NativeChatLiveSession } from './use-native-chat-live-session'
expect(screen.getByRole('button', { name: /1 changed file \+2/ })).toBeInTheDocument()
expect(screen.getByText('Run tests?')).toBeInTheDocument()
expect(screen.getByText('Allow once')).toBeInTheDocument()
expect(screen.getByText('Answered on desktop')).toBeInTheDocument()
expect(screen.getByText('Answered on another device')).toBeInTheDocument()
expect(screen.getByText('Resolved').closest('[data-native-chat-receipt]')).not.toBeNull()
expect(screen.queryByText('resolved')).toBeNull()
})
@@ -66,11 +66,26 @@ describe('resolution receipts', () => {
expect(screen.getByText('Run command?')).toBeInTheDocument()
expect(screen.getByText('pnpm test')).toBeInTheDocument()
expect(screen.getByText(label)).toBeInTheDocument()
expect(screen.getByText('Answered on phone-client')).toBeInTheDocument()
expect(screen.getByText('Answered on another device')).toBeInTheDocument()
expect(document.querySelector('time')).toHaveAttribute('datetime', new Date(1000).toISOString())
expect(screen.queryByRole('button')).toBeNull()
})
it.each([
[{ kind: 'current' }, 'this computer'],
[{ kind: 'named', name: 'My iPhone' }, 'My iPhone']
] satisfies [NativeChatResolvedPrompt['resolution']['resolvedByDevice'], string][])(
'shows the projected device identity %j',
(resolvedByDevice, device) => {
render(
<NativeChatResolutionReceipt
body={{ ...approval, resolution: { ...approval.resolution, resolvedByDevice } }}
/>
)
expect(screen.getByText(`Answered on ${device}`)).toBeInTheDocument()
}
)
it('uses the SDK display name in the compact resolved receipt', () => {
render(
<NativeChatResolutionReceipt
@@ -5,6 +5,7 @@ import {
NATIVE_CHAT_RECEIPT_COPY,
nativeChatReceiptAnswerHeading,
nativeChatReceiptAnswers,
nativeChatReceiptDevice,
nativeChatReceiptSubject,
type NativeChatResolvedPrompt
} from '../../../../shared/native-chat/native-chat-resolution-receipt'
@@ -32,6 +33,11 @@ export function NativeChatResolutionReceipt({
) : null
}
const { resolution } = body
const device = nativeChatReceiptDevice(resolution, {
current: translate('components.native-chat.receipt.thisComputer', 'this computer'),
computer: translate('components.native-chat.receipt.computer', 'the computer'),
unknown: translate('components.native-chat.receipt.otherDevice', 'another device')
})
const title = body.kind === 'approval' ? (body.displayName ?? body.title) : body.question
// Non-empty only once resolved, and then each question is listed with its answer.
const answers = nativeChatReceiptAnswers(body)
@@ -93,12 +99,12 @@ export function NativeChatResolutionReceipt({
? translate(
'components.native-chat.receipt.cancelledBy',
NATIVE_CHAT_RECEIPT_COPY.cancelledBy,
{ device: resolution.resolvedBy }
{ device }
)
: translate(
'components.native-chat.receipt.resolver',
NATIVE_CHAT_RECEIPT_COPY.resolver,
{ device: resolution.resolvedBy }
{ device }
)}
</span>
) : null}
+4 -1
View File
@@ -19651,7 +19651,10 @@
"cancelled": "Cancelled",
"resolved": "Resolved",
"resolver": "Answered on {{device}}",
"cancelledBy": "Cancelled on {{device}}"
"cancelledBy": "Cancelled on {{device}}",
"thisComputer": "this computer",
"computer": "the computer",
"otherDevice": "another device"
},
"notices": {
"contextCleared": "Context cleared",
@@ -149,6 +149,12 @@ const Resolution = z.object({
)
.optional(),
resolvedBy: z.string().nullable(),
resolvedByDevice: z
.discriminatedUnion('kind', [
z.object({ kind: z.literal('current') }),
z.object({ kind: z.literal('named'), name: z.string() })
])
.optional(),
resolvedAt: z.number().nullable()
})
@@ -128,6 +128,8 @@ export type AgentJournalResolution = {
answers?: AgentSessionQuestionAnswer[]
/** Opaque client identity of the resolver, for "answered on <device>". */
resolvedBy: string | null
/** Read-time device description; never written back into the journal. */
resolvedByDevice?: { kind: 'current' } | { kind: 'named'; name: string }
resolvedAt: number | null
}
@@ -0,0 +1,30 @@
import { expect, it } from 'vitest'
import { nativeChatReceiptDevice } from './native-chat-resolution-receipt'
import type { AgentJournalResolution } from '../agent-session/journal/agent-session-journal-types'
const resolution: AgentJournalResolution = {
state: 'resolved',
selectedOptionId: 'red',
resolvedBy: '5d857d9e-ec3f-4762-b7f4-69691f580707',
resolvedAt: 10
}
const labels = { current: 'this phone', computer: 'the computer', unknown: 'another device' }
it.each([
[undefined, 'another device'],
[{ kind: 'current' }, 'this phone'],
[{ kind: 'named', name: "Kelvin's iPhone" }, "Kelvin's iPhone"],
[{ kind: 'named', name: ' ' }, 'another device']
] satisfies [AgentJournalResolution['resolvedByDevice'], string][])(
'uses readable receipt labels for current, named and older-host devices: %j',
(resolvedByDevice, expected) => {
expect(nativeChatReceiptDevice({ ...resolution, resolvedByDevice }, labels)).toBe(expected)
}
)
it.each(['trusted-local:desktop', 'desktop-renderer'])(
"does not display older hosts' local caller key %s",
(resolvedBy) => {
expect(nativeChatReceiptDevice({ ...resolution, resolvedBy }, labels)).toBe('the computer')
}
)
@@ -1,6 +1,7 @@
import type {
AgentJournalApprovalItem,
AgentJournalQuestionItem
AgentJournalQuestionItem,
AgentJournalResolution
} from '../agent-session/journal/agent-session-journal-types'
import {
agentSessionPromptQuestions,
@@ -18,6 +19,26 @@ export const NATIVE_CHAT_RECEIPT_COPY = {
cancelledBy: 'Cancelled on {{device}}'
} as const
export function isNativeChatLocalResolver(resolvedBy: string): boolean {
return resolvedBy === 'desktop-renderer' || resolvedBy.startsWith('trusted-local:')
}
export function nativeChatReceiptDevice(
resolution: AgentJournalResolution,
labels: { current: string; computer: string; unknown: string }
): string {
const device = resolution.resolvedByDevice
if (device?.kind === 'current') {
return labels.current
}
if (device?.kind === 'named' && typeof device.name === 'string' && device.name.trim()) {
return device.name
}
return resolution.resolvedBy && isNativeChatLocalResolver(resolution.resolvedBy)
? labels.computer
: labels.unknown
}
export type NativeChatResolvedPrompt = AgentJournalApprovalItem | AgentJournalQuestionItem
/** `answer` is '' for an explicitly empty text answer and null when the answer cannot be read. */
export type NativeChatReceiptAnswer = { question: string | null; answer: string | null }
@@ -23,6 +23,31 @@ export function structuredHostStub(
notifySessionTabHidden: Mock<StructuredAgentSessionHost['notifySessionTabHidden']>
} {
let tabId: string | null = `tab-${sessionId}`
const attachResult = async (): ReturnType<StructuredAgentSessionHost['attach']> => {
const cursor = { epoch: 'epoch-a', sequence: 0 }
return {
ok: true,
replayed: false,
fence: 1,
cursor,
value: {
sessionId,
fence: 1,
unconfirmedClientMessageIds: [],
page: {
sessionId,
epoch: cursor.epoch,
direction: 'tail',
items: [],
removedItemIds: [],
submissions: [],
window: { oldest: null, newest: null, nextCursor: cursor },
hasOlder: false,
hasNewer: false
}
}
}
}
const tabs = {
getSessionTabId: vi.fn<StructuredAgentSessionHost['getSessionTabId']>((id) =>
id === sessionId ? tabId : null
@@ -62,8 +87,8 @@ export function structuredHostStub(
continueInterrupted: vi.fn(async () => ({ sessionId, outcome: 'superseded' })),
// Flat like restart-resume; installableHost rebuilds the nested usageLimitContinue member.
cancelUsageLimitContinue: vi.fn(async () => {}),
attach: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId } })),
create: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId } })),
attach: vi.fn(attachResult),
create: vi.fn(attachResult),
// Accept create-location probes so an unstubbed harness method cannot cause every ensure to refuse.
supportsCreate: vi.fn(() => true),
conversationCommand: vi.fn(async () => ({