mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 09:57:48 +00:00
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:
@@ -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'],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
Reference in New Issue
Block a user