diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index fe11677af44..8c2f088a676 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -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", diff --git a/mobile/src/session/native-chat/MobileNativeChatResolutionReceipt.tsx b/mobile/src/session/native-chat/MobileNativeChatResolutionReceipt.tsx index 69ac3c52ddf..77d39d6a945 100644 --- a/mobile/src/session/native-chat/MobileNativeChatResolutionReceipt.tsx +++ b/mobile/src/session/native-chat/MobileNativeChatResolutionReceipt.tsx @@ -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 = [ diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts index df01835ef2c..fa00d332622 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.test.ts @@ -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'], diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index 209d857c5a4..5b47c8702c3 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -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' }), diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts index 785d2ecfa34..68c41947acf 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts @@ -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 +): 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[], diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts index 340bdf44dc8..59c8ea6f95a 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -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[], - 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. diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index e175f15a2cc..5236017de6d 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -190,7 +190,10 @@ async function startOrcadRuntime( const { loadAgentSessionClaimSigner } = await import('../runtime/agent-session/ownership/agent-session-claim-identity') + let runtimeRpc: InstanceType | 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()) diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 0ac92c71368..23b60e4e7f1 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -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() diff --git a/src/main/runtime/construction/runtime-composition-parity.test.ts b/src/main/runtime/construction/runtime-composition-parity.test.ts index c0e98fba12a..3c724fd12f6 100644 --- a/src/main/runtime/construction/runtime-composition-parity.test.ts +++ b/src/main/runtime/construction/runtime-composition-parity.test.ts @@ -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', diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index ef571dba5cc..233e3338a7d 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -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 { return this.machineName.readyWithin(MACHINE_NAME_PUBLISH_WAIT_MS) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts index 8b857064ec0..dac0bd6b069 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts @@ -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 { 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 + ) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-item.ts b/src/main/runtime/rpc/methods/structured-agent-session-item.ts index 40a99e241bd..e640118a6c0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-item.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-item.ts @@ -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) + } } }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-receipt-devices.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-receipt-devices.test.ts new file mode 100644 index 00000000000..7b276eb8d96 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-receipt-devices.test.ts @@ -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' } } + } + } + ] + } + } + }) + } +) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-receipt-devices.ts b/src/main/runtime/rpc/methods/structured-agent-session-receipt-devices.ts new file mode 100644 index 00000000000..da969f9342e --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-receipt-devices.ts @@ -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 + +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, + 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, + ctx: ReceiptReader +): AgentSessionMutationResult { + 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 +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-receipt-frame-budget.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-receipt-frame-budget.test.ts new file mode 100644 index 00000000000..3637c81e421 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-receipt-frame-budget.test.ts @@ -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') + } +) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 561b2a7c7f7..c803a60c5ee 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -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() }), diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 876a0b15b23..b95872a30fc 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -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 } : {}), diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx index a598af48826..73ba4774545 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx @@ -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() }) diff --git a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx index b49176c9e3c..251b89f3460 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolutionReceipt.test.tsx @@ -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( + + ) + expect(screen.getByText(`Answered on ${device}`)).toBeInTheDocument() + } + ) + it('uses the SDK display name in the compact resolved receipt', () => { render( ) : null} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a4e989ef1ac..2e8a6d914d0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/shared/agent-session/journal/agent-session-journal-schemas.ts b/src/shared/agent-session/journal/agent-session-journal-schemas.ts index bb977811d88..4b75c418d3e 100644 --- a/src/shared/agent-session/journal/agent-session-journal-schemas.ts +++ b/src/shared/agent-session/journal/agent-session-journal-schemas.ts @@ -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() }) diff --git a/src/shared/agent-session/journal/agent-session-journal-types.ts b/src/shared/agent-session/journal/agent-session-journal-types.ts index a2c74c44996..e5f327ec7a4 100644 --- a/src/shared/agent-session/journal/agent-session-journal-types.ts +++ b/src/shared/agent-session/journal/agent-session-journal-types.ts @@ -128,6 +128,8 @@ export type AgentJournalResolution = { answers?: AgentSessionQuestionAnswer[] /** Opaque client identity of the resolver, for "answered on ". */ resolvedBy: string | null + /** Read-time device description; never written back into the journal. */ + resolvedByDevice?: { kind: 'current' } | { kind: 'named'; name: string } resolvedAt: number | null } diff --git a/src/shared/native-chat/native-chat-resolution-receipt.test.ts b/src/shared/native-chat/native-chat-resolution-receipt.test.ts new file mode 100644 index 00000000000..091879d2081 --- /dev/null +++ b/src/shared/native-chat/native-chat-resolution-receipt.test.ts @@ -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') + } +) diff --git a/src/shared/native-chat/native-chat-resolution-receipt.ts b/src/shared/native-chat/native-chat-resolution-receipt.ts index 66c68c52218..655792f95ba 100644 --- a/src/shared/native-chat/native-chat-resolution-receipt.ts +++ b/src/shared/native-chat/native-chat-resolution-receipt.ts @@ -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 } diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 5c448c51c6d..32c30e07097 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -23,6 +23,31 @@ export function structuredHostStub( notifySessionTabHidden: Mock } { let tabId: string | null = `tab-${sessionId}` + const attachResult = async (): ReturnType => { + 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((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 () => ({