diff --git a/mobile/src/session/mobile-agent-message-attribution.test.ts b/mobile/src/session/mobile-agent-message-attribution.test.ts index d0b95135ee9..66f6c790cc4 100644 --- a/mobile/src/session/mobile-agent-message-attribution.test.ts +++ b/mobile/src/session/mobile-agent-message-attribution.test.ts @@ -20,7 +20,13 @@ describe('who another agent’s message is from, on mobile', () => { }) it('names each sender, an unnamed one as an agent, and counts the rest', () => { - expect(agentMessageAttribution('Message from', from(['Coder']))).toBe('Message from Coder') + const attributed = from(['Coder']) + expect( + agentMessageAttribution('Message from', { + ...attributed, + senders: attributed.senders.map((sender) => ({ ...sender, relation: 'worker' })) + }) + ).toBe('Message from Coder') expect(agentMessageAttribution('From', from([]))).toBe('From an agent') expect(agentMessageAttribution('From', from(['A', 'B', 'C', 'D']))).toBe('From A, B, C +1') }) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index 85da60bc223..52cbebda724 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -70,7 +70,10 @@ export function mobileQueuedMessageCards( ...card, line: queuedMessageCardLine({ ...card, - text: stripSentImagePromptMarkers(card.text, card.images?.length ?? 0) + displayText: stripSentImagePromptMarkers( + card.displayText ?? card.text, + card.images?.length ?? 0 + ) }), needsAttention: queuedMessageCardNeedsAttention(card), caption: queuedMessageCardCaption(card, facts.agentName, facts.statedFailures ?? []), diff --git a/mobile/src/session/use-mobile-structured-queued-edit.test.tsx b/mobile/src/session/use-mobile-structured-queued-edit.test.tsx index 3a2c61d7885..f7922561c30 100644 --- a/mobile/src/session/use-mobile-structured-queued-edit.test.tsx +++ b/mobile/src/session/use-mobile-structured-queued-edit.test.tsx @@ -58,6 +58,74 @@ describe('mobile in-place queued Edit', () => { expect(state.hook!.queued.edit).toEqual(expect.any(Function)) }) + it('opens full canonical task input and publishes a changed Save as user-authored raw input', async () => { + const prefix = 'You are working inside Orca, a multi-agent IDE.\n=== TASK ===\n' + const message = queuedDraft({ + messageId: 'task', + body: { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: `${prefix} Task πŸ‹ [Image #1] ` }, + { type: 'image-ref', path: '/host/shot.png' } + ], + from: { + kind: 'agent', + senders: [ + { + party: { address: 'term_coord', terminalHandle: 'term_coord', orcaSessionId: null }, + name: 'Coordinator', + relation: 'coordinator' + } + ], + orchestration: { message: 'task', runId: 'r', taskId: 't', dispatchId: 'd' } + } + } + }) + await mount(message) + expect(state.hook!.queued.cards[0]).toMatchObject({ + line: ' Task πŸ‹', + attribution: 'From Coordinator' + }) + expect(state.hook!.queued.cards[0].images).toHaveLength(1) + expect(state.hook!.queued.editor!.text).toBe(`${prefix} Task πŸ‹ [Image #1] `) + await act(async () => { + expect(await state.hook!.queued.editor!.save()).toBe(true) + }) + expect( + sendRequest.mock.calls.some(([method]) => method === 'agentSession.queuedMessageUpdate') + ).toBe(false) + expect(state.hook!.queued.cards[0]).toMatchObject({ + line: ' Task πŸ‹', + attribution: 'From Coordinator' + }) + await act(async () => { + await state.hook!.queued.edit!('task') + }) + act(() => state.hook!.queued.editor!.change(`${prefix}Edited`)) + await act(async () => { + expect(await state.hook!.queued.editor!.save()).toBe(true) + }) + const update = sendRequest.mock.calls.find( + ([method]) => method === 'agentSession.queuedMessageUpdate' + ) + expect(fieldsOf(update?.[1]).text).toBe(`${prefix}Edited`) + // The current host commits changed content without the original agent attribution. + const { from: _sender, ...userBody } = withText(message.body, `${prefix}Edited`) + const saved: typeof message = { ...message, body: userBody } + act(() => state.listener?.(batchEvent([saved]))) + const [savedCard] = state.hook!.queued.cards + expect(savedCard).toMatchObject({ + line: `${prefix}Edited`, + attribution: null + }) + await act(async () => { + await state.hook!.queued.edit!('task') + }) + expect(state.hook!.queued.editor!.text).toBe(`${prefix}Edited`) + await act(async () => state.hook!.queued.editor!.cancel()) + }) + it.each([ [false, false], [true, false], diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 948f13702a1..b4359fdf169 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -19,7 +19,7 @@ import { } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, + HOST_TEST_SESSION as DEFAULT_SESSION, HOST_TEST_THREAD as THREAD, hostTestAttachParams, hostTestMessage, @@ -52,6 +52,7 @@ export type QueuedMessageTestRig = Awaited number /** Teardown records its restart offers, which `restartOffers` lists. */ @@ -61,6 +62,7 @@ export async function createQueuedMessageTestRig( probeOwner?: StructuredAgentSessionHostDeps['probeOwner'] } = {} ) { + const SESSION = options.sessionId ?? DEFAULT_SESSION const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) resetHostTestOperationIds() const store = await openTestAgentSessionRecordStore(root) @@ -83,6 +85,12 @@ export async function createQueuedMessageTestRig( let host = makeHost() const firstAttach = hostTestAttachParams(null, { ...(options.location ? { location: options.location } : {}), + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: '' + }, ...(options.agent === 'claude' ? ({ provider: 'claude', diff --git a/src/main/runtime/orchestration/agent-message-relation.ts b/src/main/runtime/orchestration/agent-message-relation.ts new file mode 100644 index 00000000000..72179e258ec --- /dev/null +++ b/src/main/runtime/orchestration/agent-message-relation.ts @@ -0,0 +1,106 @@ +import type { AgentMessageSender } from '../../../shared/agent-session/agent-session-message-source' +import { readWorkerTerminalHostScope } from '../../../shared/worker-terminal-host-scope' +import type { OrchestrationDb, DispatchContextRow } from './db' +import { canonicalOrcaSessionId } from './canonical-orca-session-id' +import { resolveOrchestrationParty } from './orchestration-party' +import { runCoordinatorKey } from './orchestration-caller-identity' + +type Party = Pick + +function sameParty(a: Party, b: Party): boolean { + if (a.orcaSessionId && b.orcaSessionId) { + return canonicalOrcaSessionId(a.orcaSessionId) === canonicalOrcaSessionId(b.orcaSessionId) + } + return Boolean(a.terminalHandle && a.terminalHandle === b.terminalHandle) +} + +function localDispatch(dispatch: DispatchContextRow): boolean { + const scope = readWorkerTerminalHostScope(dispatch.host_scope) + return scope.kind === 'absent' || (scope.kind === 'local' && scope.id === 'local') +} + +/** Delivery-time evidence only; missing, ambiguous or foreign lineage is unknown. */ +export function agentMessageRelation(args: { + db: OrchestrationDb | null + sender: AgentMessageSender['party'] + recipient: string + runId: string + reportedDispatchId?: string + recipientDispatchId: string | null +}): AgentMessageSender['relation'] { + try { + const { db, sender, runId } = args + if (!db || sender.address.startsWith('dispatch:') || args.recipient.startsWith('dispatch:')) { + return undefined + } + const run = db.getRun(runId) + if (!run) { + return undefined + } + const recipient = + args.recipient === `run:${runId}` + ? runCoordinatorKey(run) + : resolveOrchestrationParty(args.recipient, db) + const coordinator = runCoordinatorKey(run) + const dispatchFor = (party: Party, id?: string | null) => { + const candidates = id + ? [db.getDispatchContextById(id)] + : db.getActiveDispatchMailboxOwners( + party.terminalHandle ?? + (party.orcaSessionId ? `orca_session_id:${party.orcaSessionId}` : '') + ) + const matches = candidates.filter((row): row is DispatchContextRow => + Boolean( + row && + row.run_id === runId && + localDispatch(row) && + sameParty(party, { + terminalHandle: row.assignee_handle, + orcaSessionId: row.assignee_orca_session_id + }) + ) + ) + return matches.length === 1 ? matches[0] : undefined + } + const parentOf = (dispatch: DispatchContextRow | undefined): Party | undefined => { + if (!dispatch) { + return undefined + } + if (dispatch.creator_handle || dispatch.creator_orca_session_id) { + return { + terminalHandle: dispatch.creator_handle, + orcaSessionId: dispatch.creator_orca_session_id + } + } + if (dispatch.creator_dispatch_id) { + const creator = db.getDispatchContextById(dispatch.creator_dispatch_id) + return creator && creator.run_id === runId && localDispatch(creator) + ? { + terminalHandle: creator.assignee_handle, + orcaSessionId: creator.assignee_orca_session_id + } + : undefined + } + return undefined + } + const senderDispatch = dispatchFor(sender, args.reportedDispatchId) + const recipientDispatch = dispatchFor(recipient, args.recipientDispatchId) + const senderParent = parentOf(senderDispatch) + const recipientParent = parentOf(recipientDispatch) + if (!sameParty(sender, recipient)) { + if (senderParent && sameParty(senderParent, recipient)) { + return 'worker' + } + if (recipientParent && sameParty(recipientParent, sender)) { + return 'coordinator' + } + } + return (senderParent || sameParty(sender, coordinator)) && + (recipientParent || sameParty(recipient, coordinator)) + ? 'none' + : undefined + } catch { + // Lineage is informational; an unavailable database must never block delivery. + return undefined + } +} diff --git a/src/main/runtime/orchestration/dispatch-task-source.ts b/src/main/runtime/orchestration/dispatch-task-source.ts index 78662ff4a97..ce4f38bc775 100644 --- a/src/main/runtime/orchestration/dispatch-task-source.ts +++ b/src/main/runtime/orchestration/dispatch-task-source.ts @@ -14,7 +14,9 @@ export function dispatchTaskSource(args: { const { dispatch } = args return { kind: 'agent', - senders: args.from ? [agentMessageSender(args.from, args.db, args.senderName)] : [], + senders: args.from + ? [{ ...agentMessageSender(args.from, args.db, args.senderName), relation: 'coordinator' }] + : [], orchestration: { message: 'task', runId: dispatch.run_id, diff --git a/src/main/runtime/orchestration/structured-mail-source.test.ts b/src/main/runtime/orchestration/structured-mail-source.test.ts index d9b9b4e7fb0..96095c82370 100644 --- a/src/main/runtime/orchestration/structured-mail-source.test.ts +++ b/src/main/runtime/orchestration/structured-mail-source.test.ts @@ -1,5 +1,20 @@ -import { describe, expect, it } from 'vitest' -import { structuredMailSource } from './structured-mail-source' +import { OrchestrationDb } from './db' +import { describe, expect, it, vi } from 'vitest' +import { readAgentMessageSource } from '../../../shared/agent-session/agent-session-message-source' +import { agentMessageHeading } from '../../../shared/agent-session/agent-message-presentation' +import { + agentSessionConversationView, + type AgentSessionConversationView +} from '../../../shared/agent-session-conversation-view' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { orchestrationSenderName } from './orchestration-sender-name' +import * as relations from './agent-message-relation' +import * as lineage from './structured-session-lineage' +import { structuredMailSource, type MailSourceMessage } from './structured-mail-source' const SESSION = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' @@ -70,3 +85,218 @@ describe('who delivered mail is from', () => { ).toBeNull() }) }) + +it('snapshots immediate relations across completion/reload and keeps unknown evidence non-gating', () => { + const db = new OrchestrationDb(':memory:') + try { + const run = db.createRun({ + objective: 'cards', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: null + }) + const assign = (handle: string, creator: string) => + db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: handle }).id, + assigneeHandle: handle, + creator: { kind: 'terminal', handle: creator }, + maxDepth: 8 + }) + const worker = assign('term_worker', 'term_coord') + assign('term_peer', 'term_coord') + const child = assign('term_child', 'term_worker') + const mail = ( + recipient: string, + addresses: string[], + dispatchId: string | null = null, + report?: string + ) => + structuredMailSource({ + db, + mailboxHandle: recipient, + dispatchId, + senderName: () => 'Sender', + batch: addresses.map((address, i) => ({ + id: `m${i}`, + from_handle: address, + run_id: run.id, + type: report ? 'worker_done' : 'status', + payload: report ? JSON.stringify({ dispatchId: report }) : null + })) + }) + expect(mail('term_coord', ['term_worker']).senders[0].relation).toBe('worker') + expect(agentMessageHeading(mail('term_coord', ['term_worker']))).toBe('worker') + expect(mail('term_worker', ['term_coord'], worker.id).senders[0].relation).toBe('coordinator') + expect(agentMessageHeading(mail('term_worker', ['term_coord'], worker.id))).toBe('coordinator') + expect(mail('term_coord', ['term_child']).senders[0].relation).toBe('none') + const delivered = mail('term_worker', ['term_child', 'term_peer'], worker.id) + db.db.prepare("UPDATE dispatch_contexts SET status = 'completed' WHERE id = ?").run(child.id) + const reload = readAgentMessageSource(JSON.parse(JSON.stringify(delivered)))! + expect(reload.senders.map((sender) => sender.relation)).toEqual(['worker', 'none']) + expect(agentMessageHeading(reload)).toBe('generic') + expect(mail('term_worker', ['term_child'], worker.id).senders[0].relation).toBeUndefined() + expect(mail('term_worker', ['term_child'], worker.id, child.id).senders[0].relation).toBe( + 'worker' + ) + expect(mail('term_coord', ['unknown']).senders[0].relation).toBeUndefined() + db.db + .prepare('UPDATE dispatch_contexts SET creator_handle = NULL, depth = 1 WHERE id = ?') + .run(worker.id) + expect(mail('term_coord', ['term_worker']).senders[0].relation).toBeUndefined() + db.db + .prepare('UPDATE dispatch_contexts SET creator_handle = ? WHERE id = ?') + .run('term_coord', worker.id) + const good: MailSourceMessage = { + id: 'good', + from_handle: 'term_worker', + run_id: run.id, + type: 'worker_done', + payload: JSON.stringify({ dispatchId: worker.id }) + } + for (const bad of [ + { ...good, id: 'foreign', run_id: 'missing' }, + { ...good, id: 'wrong-report', payload: JSON.stringify({ dispatchId: child.id }) }, + { ...good, id: 'invalid', payload: '{' } + ]) { + const source = structuredMailSource({ + db, + mailboxHandle: 'term_coord', + dispatchId: null, + batch: [good, bad], + senderName: () => 'Sender' + }) + expect(source.senders[0].relation).toBeUndefined() + expect(source.orchestration).toMatchObject({ + messages: [{ messageId: 'good' }, { messageId: bad.id }] + }) + } + db.db + .prepare('UPDATE dispatch_contexts SET host_scope = ? WHERE id = ?') + .run(JSON.stringify({ kind: 'ssh', targetId: 'elsewhere' }), worker.id) + expect(mail('term_coord', ['term_worker']).senders[0].relation).toBeUndefined() + } finally { + vi.restoreAllMocks() + db.close() + } +}) + +it.each([100, 1000])( + 'bounds real canonical record work for repeated/distinct/unknown notices with %i workers', + (n) => { + const db = new OrchestrationDb(':memory:') + const coordinator = testOrcaSessionId('coordinator') + const sessions = Array.from({ length: n }, (_, i) => testOrcaSessionId(`worker-${i}`)) + const base = agentSessionConversationView( + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: coordinator })) + ) + const successor = testOrcaSessionId('cleared-worker') + const records: AgentSessionConversationView[] = sessions.map((sessionId, i) => ({ + ...base, + sessionId, + conversationName: `Worker ${i}` + })) + records[0] = { + ...records[0], + conversationCommand: { + command: 'clear', + state: 'completed', + phase: 'committed', + operationId: 'clear', + callerKey: 'caller', + replacementSessionId: successor + } + } + records.push( + { ...base, conversationName: 'Coordinator' }, + { ...base, sessionId: successor, conversationName: 'Worker 0' } + ) + const bySession = new Map(records.map((record) => [record.sessionId, record])) + let visits = 0 + records.forEach((record, i) => + Object.defineProperty(records, i, { + get() { + visits++ + return record + } + }) + ) + const reader = { + listRecords: () => records, + getRecord: (id: string) => bySession.get(id) ?? null + } + vi.spyOn(lineage, 'readAgentSessionRecordStore').mockReturnValue(reader) + try { + const run = db.createRun({ + objective: 'cost', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: coordinator + }) + const dispatches = sessions.slice(0, 10).map((session) => + db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: `orca_session_id:${session}`, + creator: { kind: 'session', orcaSessionId: coordinator }, + maxDepth: 8 + }) + ) + for (const evidence of ['repeated', 'distinct', 'unknown'] as const) { + const distinct = evidence === 'distinct' + const relation = vi.spyOn(relations, 'agentMessageRelation') + relation.mockClear() + const batch: MailSourceMessage[] = Array.from({ length: n }, (_, i) => ({ + id: `m${i}`, + from_handle: `orca_session_id:${!distinct || i % 10 === 0 ? successor : sessions[i % 10]}`, + run_id: evidence === 'unknown' ? 'missing' : run.id, + type: 'worker_done', + payload: JSON.stringify({ dispatchId: dispatches[distinct ? i % 10 : 0].id }) + })) + let batchReads = 0 + batch.forEach((message, i) => + Object.defineProperty(batch, i, { + get() { + batchReads++ + return message + } + }) + ) + visits = 0 + const source = structuredMailSource({ + db, + mailboxHandle: `orca_session_id:${coordinator}`, + dispatchId: null, + batch, + senderName: (party, report) => + orchestrationSenderName( + party, + { + db, + records: reader, + chatTab: () => null, + terminal: () => null, + generatedTitlesEnabled: true + }, + report + ) + }) + const groups = distinct ? 10 : 1 + // Known groups walk canonical records eight times; a missing Run ends after sender canonicalization. + expect(visits).toBe((evidence === 'unknown' ? 1 : 8 * groups) * records.length) + expect(relation).toHaveBeenCalledTimes(groups) + expect(batchReads).toBe(2 * n) + expect(source.senders[0].party.orcaSessionId).toBe(sessions[0]) + expect(source.senders.map((sender) => [sender.name, sender.relation])).toEqual( + Array.from({ length: groups }, (_, i) => [ + `Worker ${i}`, + evidence === 'unknown' ? undefined : 'worker' + ]) + ) + expect( + source.orchestration?.message === 'mail-notice' && source.orchestration.messages + ).toHaveLength(n) + } + } finally { + vi.restoreAllMocks() + db.close() + } + } +) diff --git a/src/main/runtime/orchestration/structured-mail-source.ts b/src/main/runtime/orchestration/structured-mail-source.ts index b580a4c6f4a..a5be32fc7d3 100644 --- a/src/main/runtime/orchestration/structured-mail-source.ts +++ b/src/main/runtime/orchestration/structured-mail-source.ts @@ -6,6 +6,7 @@ import type { } from '../../../shared/agent-session/agent-session-message-source' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { MessageRow, OrchestrationDb } from './db' +import { agentMessageRelation } from './agent-message-relation' import { agentMessageSender, type SenderNameResolver } from './agent-message-sender' import type { OrchestrationCliCommand } from './cli-command' import { formatMessagePointer } from './formatter' @@ -23,23 +24,54 @@ export function structuredMailSource(input: { batch: readonly MailSourceMessage[] senderName: SenderNameResolver }): AgentMessageSource { - const senders = new Map() - for (const { from_handle: address } of input.batch) { - if (!senders.has(address)) { - senders.set( - address, - agentMessageSender( - address, - input.db, - input.senderName, - reportedDispatchId(input.batch, address) - ) - ) + const groups = new Map() + for (const message of input.batch) { + let group = groups.get(message.from_handle) + if (!group) { + group = [] + groups.set(message.from_handle, group) } + group.push({ message, report: reportFact(message) }) + } + const senders: AgentMessageSender[] = [] + for (const [address, group] of groups) { + const reported = group.find(({ report }) => report.kind === 'valid')?.report + const sender = agentMessageSender( + address, + input.db, + input.senderName, + reported?.kind === 'valid' ? reported.dispatchId : undefined + ) + const relations = new Map() + const values = group.map(({ message, report }) => { + // Sender and DB are fixed by this group/invocation; every varying evidence field is keyed. + const key = JSON.stringify([input.mailboxHandle, input.dispatchId, message.run_id, report]) + if (!relations.has(key)) { + relations.set( + key, + report.kind === 'invalid' + ? undefined + : agentMessageRelation({ + db: input.db, + sender: sender.party, + recipient: input.mailboxHandle, + runId: message.run_id, + reportedDispatchId: report.kind === 'valid' ? report.dispatchId : undefined, + recipientDispatchId: input.dispatchId + }) + ) + } + return relations.get(key) + }) + const relation = values[0] + senders.push({ + ...sender, + ...(relation && values.every((value) => value === relation) ? { relation } : {}) + }) } return { kind: 'agent', - senders: [...senders.values()], + senders, orchestration: { message: 'mail-notice', mailbox: input.mailboxHandle, @@ -77,28 +109,27 @@ export function structuredMailNotice(input: { } } -/** The dispatch a sender's own `worker_done` here reports: the task it just finished, whose - * dispatch that report already settled. */ -function reportedDispatchId( - batch: readonly MailSourceMessage[], - address: string -): string | undefined { - for (const message of batch) { - if (message.from_handle !== address || message.type !== 'worker_done' || !message.payload) { - continue - } +type ReportFact = { kind: 'mail' } | { kind: 'invalid' } | { kind: 'valid'; dispatchId: string } + +function reportFact(message: MailSourceMessage): ReportFact { + if (message.type !== 'worker_done') { + return { kind: 'mail' } + } + if (message.payload) { try { const payload: unknown = JSON.parse(message.payload) - const dispatchId = - typeof payload === 'object' && payload !== null && 'dispatchId' in payload - ? payload.dispatchId - : undefined - if (typeof dispatchId === 'string' && dispatchId.length > 0) { - return dispatchId + if ( + typeof payload === 'object' && + payload !== null && + 'dispatchId' in payload && + typeof payload.dispatchId === 'string' && + payload.dispatchId.length > 0 + ) { + return { kind: 'valid', dispatchId: payload.dispatchId } } } catch { - // A payload that does not parse names no dispatch. + // Invalid evidence stays unknown; delivery and the other messages remain intact. } } - return undefined + return { kind: 'invalid' } } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index 4b892c19535..5357756dcbc 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -23,6 +23,7 @@ const IDENTITY: StructuredWorkerIdentity = { function harness(options: { /** False: the session cannot be read (not attached). */ + relationReadFails?: boolean attached?: boolean dispatchState?: 'accepted' | 'rejected' | 'unknown' /** The chat was busy: its queue holds the pointer as a card. */ @@ -62,7 +63,14 @@ function harness(options: { () => undefined ) const stored = new Map() + const relationRead = vi.fn(() => { + if (options.relationReadFails) { + throw new Error('lineage unavailable') + } + return undefined + }) const db = { + getRun: relationRead, getDispatchContextById: () => ({ run_id: 'run_1' }), getOutstandingMailboxDelivery: (handle: string) => ((options.outstandingRunDelivery ?? false) && handle.startsWith('run:')) || @@ -104,6 +112,7 @@ function harness(options: { }) return { delivery, + relationRead, markAsDelivered, send: sendMock, readContext, @@ -232,19 +241,30 @@ describe('structured mailbox pointer delivery', () => { expect(markAsDelivered).toHaveBeenCalledWith(['m1'], 'dispatch:d1') }) - it('rechecks a held notice when the answer edge lands during its host call', async () => { - const { delivery, send, markAsDelivered, setAwaitingHuman } = harness({}) - setAwaitingHuman(true) - send.mockImplementationOnce(async () => { - setAwaitingHuman(false) - delivery.onJournalActivity('session-1') - return { kind: 'held' } - }) - delivery.deliverForHandle('dispatch:d1') - await flush() - expect(send).toHaveBeenCalledTimes(2) - expect(markAsDelivered).toHaveBeenCalledWith(['m1'], 'dispatch:d1') - }) + it.each([false, true])( + 'rechecks the original dispatch and direct failed-relation held notice (failure=%s)', + async (relationReadFails) => { + const { delivery, send, markAsDelivered, setAwaitingHuman, relationRead } = harness({ + relationReadFails, + ...(relationReadFails ? { mailbox: 'term_worker' } : {}) + }) + setAwaitingHuman(true) + send.mockImplementationOnce(async () => { + setAwaitingHuman(false) + delivery.onJournalActivity('session-1') + return { kind: 'held' } + }) + delivery.deliverForHandle(relationReadFails ? 'term_worker' : 'dispatch:d1') + await flush() + expect(send).toHaveBeenCalledTimes(2) + expect(relationRead).toHaveBeenCalledTimes(relationReadFails ? 2 : 0) + expect(send.mock.calls[1][0].body.from?.senders[0].relation).toBeUndefined() + expect(markAsDelivered).toHaveBeenCalledWith( + ['m1'], + relationReadFails ? 'term_worker' : 'dispatch:d1' + ) + } + ) it('sends the pointer through the chat, with who it is from, and counts it pointed once queued', async () => { const { delivery, send, markAsDelivered, stored } = harness({ queued: true }) diff --git a/src/main/runtime/rpc/methods/orchestration/agent-message-command-contract.test.ts b/src/main/runtime/rpc/methods/orchestration/agent-message-command-contract.test.ts new file mode 100644 index 00000000000..d34bb6a65be --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/agent-message-command-contract.test.ts @@ -0,0 +1,278 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { createOrchestrationRpcHarness } from './rpc-test-harness' +import { resolveOrchestrationSessionCaller } from '../../orchestration-session-caller' +import { setStructuredAgentSessionHost } from '../../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER, + type QueuedMessageTestRig +} from '../../../../native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture' +import { + hostTestAttachParams, + hostTestOperationId +} from '../../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { testOrcaSessionId } from '../../../../../shared/orca-session-address-test-fixture' +import { agentMessageDisplayText } from '../../../../../shared/agent-session/agent-message-display-text' +import { readAgentMessageSource } from '../../../../../shared/agent-session/agent-session-message-source' +import { nativeChatMessageCopyText } from '../../../../../shared/native-chat-message-copy-text' +import { + projectQueuedMessageCards, + queuedMessageCardLine +} from '../../../../../shared/structured-agent-session-queued-cards' +import { projectStructuredAgentSessionStatusSummary } from '../../../../../shared/structured-agent-session-projection' +import { latestStructuredAgentSessionPrompt } from '../../../../../shared/structured-agent-session-latest-request' +import { isOrcaDispatchPrompt } from '../../../../../shared/agent-row-primary-text' +import * as placement from './worker/worker-start-agent-placement' +import * as topology from './worker/worker-topology' + +const h = createOrchestrationRpcHarness() +let rig: QueuedMessageTestRig | undefined +const COORD = testOrcaSessionId('command-coordinator') +const WORKER = testOrcaSessionId('command-worker') +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig?.dispose() + rig = undefined + h.cleanup() + vi.unstubAllEnvs() + vi.restoreAllMocks() + vi.doUnmock('../../../../../cli/runtime-client') + vi.doUnmock('../../../../../cli/format') + vi.resetModules() + process.exitCode = 0 +}) + +it.each(['worker-start', 'dispatch'] as const)( + 'real %s argv constructs, persists and delivers canonical input with a derived task display', + async (command) => { + rig = await createQueuedMessageTestRig({ sessionId: WORKER }) + const current = rig + setStructuredAgentSessionHost(current.host) + const attach = hostTestAttachParams(null, { + envelope: { + sessionId: COORD, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: '' + } + }) + expect(await current.host.attach(QUEUED_RIG_CALLER, attach)).toMatchObject({ ok: true }) + const { db, runtime } = h.setup(false) + vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue() + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + type WorkspaceLookup = { + showManagedTerminalWorkspace: ( + ...args: Parameters + ) => Promise>['worktree']> + } + vi.spyOn( + runtime, + 'showManagedTerminalWorkspace' + ).mockResolvedValue({ + id: 'workspace-1', + repoId: 'repo' + }) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockReturnValue({ + runtimeId: runtime.getRuntimeId(), + terminalHandle: 'term_worker', + ptyId: 'pty', + worktreeId: 'workspace-1', + paneKey: 'tab:11111111-1111-4111-8111-111111111111', + processIncarnation: 'fixture:1', + launchTokenHash: null, + hostScope: { kind: 'local', hostId: 'local' } + }) + // Placement/provider are external fixtures; admission, constructors, host, journal, queue and settlement run in production. + vi.spyOn(placement, 'placeWorkerAgent').mockImplementation(async (args) => ({ + mode: args.mode, + worktree: { id: 'workspace-1', repoId: 'repo' }, + terminalHandle: 'term_worker', + structuredSession: { + host: current.host, + identity: { + sessionId: WORKER, + handle: 'term_worker', + agent: args.agent ?? null, + paneKey: 'tab:11111111-1111-4111-8111-111111111111', + processIncarnation: 'fixture:1', + worktreeId: 'workspace-1', + hostScope: { kind: 'local', hostId: 'local' } + }, + fence: current.store.requireWritableRecord(WORKER).lease.runtimeFence + }, + setupReceipt: placement.EXISTING_WORKTREE_SETUP + })) + vi.spyOn(topology, 'monitorWorkerSetup').mockImplementation(() => {}) + const run = db.createRun({ + objective: 'cards', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: COORD + }) + vi.stubEnv('ORCA_AGENT_SESSION_ID', COORD) + vi.stubEnv('ORCA_TERMINAL_HANDLE', '') + vi.stubEnv('ORCA_PAIRING_CODE', '') + vi.stubEnv('ORCA_ENVIRONMENT', '') + const requests: string[] = [] + vi.doMock('../../../../../cli/runtime-client', async (original) => ({ + ...(await original>()), + RuntimeClient: function () { + return { + isRemote: false, + async call(method: string, params: Record = {}) { + const admitted = await resolveOrchestrationSessionCaller( + runtime, + { + id: 'cli', + authToken: 'test-token', + method, + params, + orchestrationCompatibilityEvidence: { agentSessionId: COORD } + }, + undefined + ) + requests.push(method) + const rpc = h.findMethod(method) + const parsed = rpc.params?.parse(admitted.request.params) + return { + result: await rpc.handler(parsed, { + runtime, + orchestrationCaller: admitted.caller, + orchestrationCompatibilityEvidence: + admitted.request.orchestrationCompatibilityEvidence + }) + } + } + } + } + })) + const output = vi.fn() + vi.doMock('../../../../../cli/format', () => ({ printResult: output })) + const cliPath = '../../../../../cli/index' + const { main }: { main(argv: string[]): Promise } = await import(cliPath) + const task = ' Current task πŸ‹\r\n\r\n=== TASK ===\nDo not clip the task. ' + let argv: string[] + let openingSend: string | undefined + if (command === 'dispatch') { + openingSend = await current.workingSend() + const created = db.createTask({ runId: run.id, spec: task }) + argv = [ + 'orchestration', + 'dispatch', + '--task', + created.id, + '--to', + `orca_session_id:${WORKER}`, + '--inject', + '--return-preamble', + '--json' + ] + } else { + argv = [ + 'orchestration', + 'worker-start', + '--spec', + task, + '--agent', + 'codex', + '--timeout-ms', + '1000', + '--json' + ] + } + const running = main(argv) + if (command === 'worker-start') { + await eventually(() => expect(current.dispatch).toHaveBeenCalled()) + const id = current.dispatch.mock.calls[0][0].clientMessageId + await current.settleAccepted(id, 'task-turn') + } + await running + expect(requests).toContain( + command === 'dispatch' ? 'orchestration.dispatch' : 'orchestration.workerStart' + ) + const snapshot = await current.host.journalSnapshot(WORKER) + const history = await current.host.history({ sessionId: WORKER, direction: 'tail' }) + if (!history.ok) { + throw new Error('cannot reload real history') + } + const queued = history.page.queuedMessages?.[0] + if (command === 'dispatch') { + expect(queued, 'busy --inject must enter the queue').toBeDefined() + if (!queued) { + throw new Error('busy dispatch bypassed queue') + } + } else { + expect(queued, 'worker-start sends immediately').toBeUndefined() + } + const body = + queued?.body ?? + snapshot.items.find((item) => item.body.kind === 'message' && item.body.from)?.body + if (!body || body.kind !== 'message') { + throw new Error('production host did not persist task') + } + const from = readAgentMessageSource(JSON.parse(JSON.stringify(body.from))) + expect(from?.senders[0].party.orcaSessionId).toBe(COORD) + expect(from?.senders[0].relation).toBe('coordinator') + expect(agentMessageDisplayText(body.blocks, from)).toBe(task) + const raw = nativeChatMessageCopyText(body) + expect(raw).toContain('You are working inside Orca') + expect(raw.endsWith(task)).toBe(true) + const items = [{ itemId: 'task', sequence: 1, revision: 1, observedAt: 1, body }] + expect(latestStructuredAgentSessionPrompt(items)).toBe(raw) + const prompt = projectStructuredAgentSessionStatusSummary(items).latestPrompt + expect(isOrcaDispatchPrompt(prompt)).toBe(true) + expect(prompt).toContain('=== TASK === Current task') + if (from?.orchestration?.message !== 'task') { + throw new Error('task identity missing') + } + expect(prompt).toContain(`Your task ID is: ${from.orchestration.taskId}`) + if (queued) { + const [card] = projectQueuedMessageCards([queued], [], {}) + expect(queuedMessageCardLine(card)).toBe(task) + expect(card.text).toBe(raw) // Exactly what the editor opens. + const journal = current.host.collaboratorsForTests().sessions.get(WORKER)!.journal + const edited = `${raw.slice(0, raw.length - task.length)}Edited task` + const fields = { + messageId: queued.messageId, + expectedBodyFingerprint: journal.queuedMessages.get(queued.messageId)!.fingerprint, + text: edited + } + expect( + await current.host.queuedMessageUpdate(QUEUED_RIG_CALLER, { + envelope: current.envelope( + fields, + 'agentSession.queuedMessageUpdate', + hostTestOperationId() + ), + ...fields + }) + ).toMatchObject({ ok: true }) + const page = await current.host.history({ sessionId: WORKER, direction: 'tail' }) + if (!page.ok) { + throw new Error('cannot reopen queue') + } + const reopened = page.page.queuedMessages!.find((row) => row.messageId === queued.messageId)! + expect(nativeChatMessageCopyText(reopened.body)).toBe(edited) + expect(reopened.body).not.toHaveProperty('from') + if (!openingSend) { + throw new Error('busy dispatch has no opening send') + } + expect(await current.sendNow(queued.messageId)).toMatchObject({ ok: true }) + await current.settleAccepted(openingSend, 'opening-task-turn') + await eventually(async () => { + const handoff = await current.handoffId(queued.messageId) + const delivered = current.dispatch.mock.calls.find( + ([args]) => args.clientMessageId === handoff + ) + expect(delivered).toBeDefined() + expect(nativeChatMessageCopyText(delivered![0].body)).toBe(edited) + }) + } else { + expect(nativeChatMessageCopyText(current.dispatch.mock.calls[0][0].body)).toBe(raw) + } + } +) diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageCard.fixture.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageCard.fixture.tsx new file mode 100644 index 00000000000..84338267128 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageCard.fixture.tsx @@ -0,0 +1,138 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { defineFixture } from '../../component-fixtures/component-fixture' +import { makeTab } from '@/store/slices/store-test-helpers' +import type { AgentMessageSource } from '../../../../shared/agent-session/agent-session-message-source' +import { NativeChatAgentMessageCard } from './NativeChatAgentMessageCard' +import { NativeChatUserMessageRow } from './NativeChatUserMessageRow' + +const workspace = 'agent-message-fixture' +const image = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPAAAAA8CAIAAADXHaAKAAAAvklEQVR4nO3SUQkAIBTAwBfUAAawP5YQhHFwAfaxWftAxnwvgIcMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSbm/mJH95bvMnAAAAABJRU5ErkJggg==' +const prose = + 'First paragraph with **real Markdown**, a [web link](https://example.com) and inline `code`.\n\nSecond paragraph keeps its own spacing. This longer line wraps differently in the narrow frame, so Show more is based on rendered lines.\n\n![Example image](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPAAAAA8CAIAAADXHaAKAAAAvklEQVR4nO3SUQkAIBTAwBfUAAawP5YQhHFwAfaxWftAxnwvgIcMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSTE0KYYmxdCkGJoUQ5NiaFIMTYqhSbm/mJH95bvMnAAAAABJRU5ErkJggg==)\n\nLast paragraph after the image.' +function cards() { + const human: NativeChatMessage = { + id: 'human', + role: 'user', + source: 'transcript', + blocks: [{ type: 'text', text: 'An ordinary human message.' }], + timestamp: 1_800_000_000_000 + } + return ( +
+ {(['task', 'coordinator', 'worker', 'generic'] as const).map((kind, i) => { + const from: AgentMessageSource = { + kind: 'agent', + senders: (kind === 'generic' + ? ['A sender on another host with a long name', null, 'Parser worker', 'Fourth sender'] + : ['Parser worker'] + ).map((name, index) => { + const address = + kind === 'generic' && index === 0 + ? 'dispatch:missing' + : kind === 'task' + ? 'term_progress' + : `term_sender${index || ''}` + return { + party: { + address, + terminalHandle: kind === 'generic' && index === 0 ? null : address, + orcaSessionId: null + }, + name, + ...(kind === 'worker' || kind === 'coordinator' ? { relation: kind } : {}) + } + }), + orchestration: + kind === 'task' + ? { message: 'task', runId: 'r', taskId: 't', dispatchId: 'd' } + : { + message: 'mail-notice', + mailbox: 'term_recipient', + dispatchId: null, + messages: [] + } + } + return ( + {}} + /> + ) + })} + {}} + /> +
+ ) +} +const agentStatusByPaneKey: Record = {} +for (const [id, agentType, state] of [ + ['sender', 'codex', 'done'], + ['progress', 'claude', 'working'] +] as const) { + const paneKey = `${id}:11111111-1111-4111-8111-111111111111` + agentStatusByPaneKey[paneKey] = { + paneKey, + worktreeId: workspace, + terminalHandle: `term_${id}`, + agentType, + state, + prompt: 'Parser worker', + updatedAt: 1_800_000_000_000, + stateStartedAt: 1_800_000_000_000, + stateHistory: [], + ...(state === 'working' + ? { programStatus: { state, progress: 40, observedAt: 1_800_000_000_000 } } + : {}) + } +} +const store = { + activeWorktreeId: workspace, + activeWorkspaceExecutionHostId: 'local' as const, + tabsByWorktree: { + [workspace]: ['sender', 'progress'].map((id) => makeTab({ id, worktreeId: workspace })) + }, + agentStatusByPaneKey +} + +export const wide = defineFixture({ width: 720, store, render: cards }) +export const narrow = defineFixture({ width: 340, store, render: cards }) +export const expanded = defineFixture({ + width: 720, + store, + render: cards, + interact: (frame) => { + for (const button of frame.querySelectorAll('button')) { + if (button.textContent === 'Show more') { + button.click() + } + } + } +}) diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageCard.test.tsx new file mode 100644 index 00000000000..a3118457cde --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageCard.test.tsx @@ -0,0 +1,614 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, renderHook, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { render } from './native-chat-tooltip-test-harness' +import { useAppStore } from '@/store' +import { makeTab, makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers' +import { readStoreListenerCount } from '@/store/store-listener-census' +import { getKnownExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { resolveStructuredAgentSessionOwner } from '@/runtime/structured-agent-session-owner' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { AgentMessageSenderView } from '../../../../shared/agent-session/agent-message-presentation' +import { runtimeEnvironmentStatusFromSnapshot } from '../../../../shared/runtime-host-status' +import * as presentation from '@/lib/program-status/agent-row-presentation' +import * as cache from '@/components/dashboard/worktree-agent-rows-cache' +import * as selectors from '@/components/sidebar/worktree-agent-row-selectors' +import { useAgentMessageSenderView } from './native-chat-agent-message-sender-view' +import { useAgentMessageSenderSource } from './native-chat-agent-message-sender-source' +import { AgentStateDot } from '@/components/AgentStateDot' +import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../../shared/agent-session-write-notice-copy' +import * as markdown from './NativeChatMarkdown' +import * as display from '../../../../shared/agent-session/agent-message-display-text' +import type * as SonnerModule from 'sonner' +import type * as RuntimeRpc from '@/runtime/runtime-rpc-client' +import type { AgentMessageSource } from '../../../../shared/agent-session/agent-session-message-source' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' +import { nativeChatSlotCopyText } from './native-chat-message-copy-text' +import { NativeChatAgentMessageCard } from './NativeChatAgentMessageCard' + +const navigation = vi.hoisted(() => ({ rpc: vi.fn(), toast: vi.fn() })) +vi.mock('@/runtime/runtime-rpc-client', async (original) => ({ + ...(await original()), + callRuntimeRpc: navigation.rpc +})) +vi.mock('sonner', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + toast: Object.assign( + function (this: unknown, ...args: Parameters) { + return original.toast.apply(this, args) + }, + original.toast, + { error: navigation.toast } + ) + } +}) +const initialStore = useAppStore.getState() +const LEAF = '11111111-1111-4111-8111-111111111111' +const renders = new Map() +const views = new Map() +function status(i: number, worktreeId = 'workspace'): AgentStatusEntry { + return { + paneKey: `tab-${i}:${LEAF}`, + worktreeId, + terminalHandle: `term_${i}`, + agentType: 'claude', + state: 'working', + prompt: 'work', + updatedAt: 2_000_000, + stateStartedAt: 2_000_000, + stateHistory: [] + } +} +function Probe({ + id, + workspace = 'workspace', + remote = false +}: { + id: number + workspace?: string + remote?: boolean +}) { + const value = useAgentMessageSenderView( + { + party: { address: `term_${id}`, terminalHandle: `term_${id}`, orcaSessionId: null }, + name: 'Sender' + }, + workspace, + remote ? { kind: 'environment', environmentId: 'remote' } : { kind: 'local' } + ) + views.set(id, value) + renders.set(id, (renders.get(id) ?? 0) + 1) + return null +} +function write(i: number, update: Partial) { + const state = useAppStore.getState() + const key = status(i).paneKey + act(() => + useAppStore.setState({ + agentStatusByPaneKey: { + ...state.agentStatusByPaneKey, + [key]: { ...state.agentStatusByPaneKey[key], ...update } + }, + agentStatusEpoch: state.agentStatusEpoch + 1 + }) + ) +} + +let height = 48 +let resize: () => void +let disconnects = 0 +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(2_000_000) + renders.clear() + views.clear() + useAppStore.setState( + { + ...initialStore, + activeWorktreeId: 'workspace', + activeWorkspaceExecutionHostId: 'local', + tabsByWorktree: { workspace: [makeTab({ id: 'tab-0', worktreeId: 'workspace' })] }, + unifiedTabsByWorktree: {}, + agentStatusByPaneKey: { [status(0).paneKey]: status(0) }, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + paneForegroundAgentByPaneKey: {}, + agentStatusEpoch: 1 + }, + true + ) + navigation.rpc.mockReset() + navigation.toast.mockReset() + height = 48 + disconnects = 0 + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => { + return height + }) + const computedStyle = getComputedStyle + vi.stubGlobal('getComputedStyle', (element: Element) => { + const style = computedStyle(element) + return new Proxy(style, { + get(target, key) { + if (key === 'lineHeight') { + return '24px' + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the style facade delegates native properties to the real style receiver, including binding its methods below. + const value = target[key as keyof CSSStyleDeclaration] + return typeof value === 'function' ? value.bind(target) : value + } + }) + }) + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: () => void) { + resize = callback + } + observe() {} + disconnect() { + disconnects++ + } + } + ) +}) +afterEach(() => { + cleanup() + useAppStore.setState(initialStore, true) + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +const from: AgentMessageSource = { + kind: 'agent', + orchestration: null, + senders: ['Build cards', 'Other sender'].map((name, i) => ({ + name, + party: { address: `term_${i}`, terminalHandle: `term_${i}`, orcaSessionId: null } + })) +} +const context = { worktreeId: 'workspace', worktreePath: null, target: { kind: 'local' as const } } +function card(message: NativeChatMessage) { + return ( + {}} + /> + ) +} +function slots(message: NativeChatMessage) { + return buildNativeChatTranscriptSlots({ + messages: [message], + turnKeys: [undefined], + liveTurnKey: undefined, + receipts: new Map(), + turnStatuses: { active: null, completedByTurn: {} }, + turnDiffs: new Map(), + expandedTurnKeys: new Set(), + isWorking: false, + lifecycleWorking: false + }) +} + +it.each(['task', 'mail'] as const)( + 'keeps native %s progress independent from Markdown and reparses own edits', + async (kind) => { + const paneKey = status(0).paneKey + const entry = { + ...status(0), + programStatus: { state: 'working' as const, progress: 40, observedAt: 2_000_000 } + } + useAppStore.setState({ agentStatusByPaneKey: { [paneKey]: entry } }) + const prefix = + kind === 'task' ? 'You are working inside Orca, a multi-agent IDE.\n=== TASK ===\n' : '' + const message: NativeChatMessage = { + id: `progress-${kind}`, + role: 'user', + source: 'transcript', + timestamp: 1, + from: { + ...from, + orchestration: + kind === 'task' + ? { message: 'task', runId: 'r', taskId: 't', dispatchId: 'd' } + : { message: 'mail-notice', mailbox: 'term_0', dispatchId: null, messages: [] } + }, + blocks: [ + { + type: 'text', + text: `${prefix}Current **task** with [link](https://example.com) and ![image](https://example.com/image.png).\n\nSecond paragraph with [another link](https://example.com/second).` + } + ] + } + const convert = vi.spyOn(display, 'agentMessageTranscriptText') + const renderMarkdown = vi.spyOn(markdown, 'NativeChatMarkdown') + let mounted = render(card(message)) + expect( + screen.getByText(kind === 'task' ? 'Task from your coordinator' : 'Message from') + ).toBeInTheDocument() + expect(screen.getByText('task', { selector: 'strong' })).toBeInTheDocument() + expect(convert).toHaveBeenCalled() + expect(renderMarkdown).toHaveBeenCalled() + expect(mounted.container.querySelectorAll('p')).toHaveLength(2) + expect(screen.getByRole('link', { name: 'link' })).toHaveAttribute( + 'href', + 'https://example.com' + ) + expect(screen.getByRole('img', { name: 'image' })).toHaveAttribute( + 'src', + 'https://example.com/image.png' + ) + const text = + 'Current **task** with [link](https://example.com) and ![image](https://example.com/image.png).\n\nSecond paragraph with [another link](https://example.com/second).' + const writeClipboardText = vi.fn(async () => {}) + vi.stubGlobal('api', { ui: { writeClipboardText } }) + navigation.rpc.mockRejectedValue(new Error('unreachable')) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Build cards' }))) + expect(navigation.rpc).toHaveBeenCalledWith({ kind: 'local' }, 'orchestration.partyLocation', { + address: 'term_0' + }) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Other sender' }))) + expect(navigation.rpc).toHaveBeenLastCalledWith( + { kind: 'local' }, + 'orchestration.partyLocation', + { address: 'term_1' } + ) + expect(navigation.toast).toHaveBeenCalledWith( + `${AGENT_SESSION_WRITE_NOTICE_COPY.unreachable} ${AGENT_SESSION_WRITE_NOTICE_COPY.tryAgain}` + ) + expect(screen.queryByRole('button', { name: 'Show more' })).toBeNull() + act(() => { + height = 120 + resize() + }) + const more = screen.getByRole('button', { name: 'Show more' }) + expect(more).toHaveAttribute('aria-expanded', 'false') + const clip = document.getElementById(more.getAttribute('aria-controls')!)! + const first = screen.getByRole('link', { name: 'link' }) + const second = screen.getByRole('link', { name: 'another link' }) + const documentFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true) + first.focus() + clip.scrollTop = 74 + second.focus() + expect(second).toHaveFocus() + expect(clip.scrollTop).toBe(74) + documentFocus.mockReturnValue(false) + second.blur() + expect(clip.scrollTop).toBe(74) + documentFocus.mockReturnValue(true) + second.focus() + more.focus() + expect(more).toHaveFocus() + expect(clip.scrollTop).toBe(0) + expect(more).toHaveAttribute('aria-expanded', 'false') + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Copy message' }))) + expect(writeClipboardText).toHaveBeenCalledWith(text) + expect(nativeChatSlotCopyText(slots(message), message.id)).toBe(text) + fireEvent.click(screen.getByRole('button', { name: 'Show more' })) + expect(screen.getByRole('button', { name: 'Show less' })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + fireEvent.click(screen.getByRole('button', { name: 'Show less' })) + act(() => { + height = 48 + resize() + }) + expect(screen.queryByRole('button', { name: 'Show more' })).toBeNull() + for (const remount of [false, true]) { + if (remount) { + mounted.unmount() + mounted = render(card(message)) + } + const ring = screen.getByRole('progressbar', { name: 'Working Β· 40%' }) + expect(ring).toHaveAttribute('aria-valuenow', '40') + expect(ring.querySelector('circle[pathLength="100"]')).toHaveAttribute( + 'stroke-dasharray', + '40 100' + ) + } + for (const spy of [convert, renderMarkdown]) { + spy.mockClear() + } + for (const progress of [60, null, 60, 0, 100]) { + act(() => + useAppStore + .getState() + .setAgentProgramStatus( + paneKey, + progress === null ? null : { state: 'working', progress, observedAt: 2_000_000 } + ) + ) + if (progress === null) { + expect(screen.queryByRole('progressbar')).toBeNull() + expect(screen.getByLabelText('Working')).toBeInTheDocument() + } else { + const ring = screen.getByRole('progressbar', { name: `Working Β· ${progress}%` }) + expect(ring).toHaveAttribute('aria-valuenow', String(progress)) + expect(ring.querySelector('circle[pathLength="100"]')).toHaveAttribute( + 'stroke-dasharray', + `${progress} 100` + ) + expect(screen.queryByLabelText('Done')).toBeNull() + } + for (const spy of [convert, renderMarkdown]) { + expect(spy).not.toHaveBeenCalled() + } + } + act(() => + useAppStore.setState({ + agentStatusByPaneKey: { [paneKey]: { ...entry, state: 'done' } }, + agentStatusEpoch: 2 + }) + ) + expect(screen.getByLabelText('Done')).toBeInTheDocument() + expect(screen.queryByRole('progressbar')).toBeNull() + for (const spy of [convert, renderMarkdown]) { + expect(spy).not.toHaveBeenCalled() + } + const edited = { ...message, blocks: [{ type: 'text' as const, text: `${prefix}Edited task` }] } + mounted.rerender(card(edited)) + expect(screen.getByText('Edited task')).toBeInTheDocument() + expect(convert).toHaveBeenCalled() + expect(renderMarkdown).toHaveBeenCalled() + const copyButton = screen.getByRole('button', { name: 'Copy message' }) + await act(async () => fireEvent.click(copyButton)) + expect(copyButton).toHaveAccessibleName('Copied') + expect(writeClipboardText).toHaveBeenCalledWith('Edited task') + expect(nativeChatSlotCopyText(slots(edited), edited.id)).toBe('Edited task') + const multiple: NativeChatMessage = { + ...edited, + blocks: [...edited.blocks, { type: 'text', text: 'Another paragraph' }] + } + mounted.rerender(card(multiple)) + await act(async () => fireEvent.click(copyButton)) + expect(writeClipboardText).toHaveBeenCalledWith(`${prefix}Edited task\n\nAnother paragraph`) + expect(nativeChatSlotCopyText(slots(multiple), multiple.id)).toBe( + `${prefix}Edited task\n\nAnother paragraph` + ) + mounted.unmount() + expect(disconnects).toBeGreaterThan(0) + } +) + +it.each([100, 1000])( + 'shares actual row acquisition and isolates selected progress across %i cards', + (n) => { + let visits = 0 + const live = new Proxy( + Object.fromEntries( + Array.from({ length: n }, (_, i) => { + const entry = status(i) + return [entry.paneKey, entry] + }) + ), + { + ownKeys(target) { + const keys = Object.getOwnPropertyNames(target) + visits += keys.length + return keys + } + } + ) + useAppStore.setState({ + agentStatusByPaneKey: live, + tabsByWorktree: { + workspace: Array.from({ length: n }, (_, i) => + makeTab({ id: `tab-${i}`, worktreeId: 'workspace' }) + ) + }, + terminalLayoutsByTabId: Object.fromEntries( + Array.from({ length: n }, (_, i): [string, TerminalLayoutSnapshot] => [ + `tab-${i}`, + { root: { type: 'leaf', leafId: LEAF }, activeLeafId: LEAF, expandedLeafId: null } + ]) + ) + }) + const normalize = vi.spyOn(presentation, 'resolveRowProgramStatus') + const mounted = render( + <> + {Array.from({ length: n }, (_, id) => ( + + ))} + + ) + // Handle lookup, visible-authority acquisition and sidebar indexing each enumerate the N live entries. + expect(visits).toBe(n * 3) + expect(normalize).toHaveBeenCalledTimes(n) + for (let i = 0; i < n; i++) { + expect(views.get(i)?.dot).toBe('working') + } + const before = new Map(renders) + visits = 0 + normalize.mockClear() + act(() => useAppStore.setState({ sidebarBody: 'agents' })) + expect(visits).toBe(0) + expect(normalize).not.toHaveBeenCalled() + expect(renders).toEqual(before) + for (const progress of [40, 60]) { + normalize.mockClear() + const epoch = useAppStore.getState().agentStatusEpoch + act(() => + useAppStore.getState().setAgentProgramStatus(status(0).paneKey, { + state: 'working', + progress, + observedAt: 2_000_000 + }) + ) + expect(useAppStore.getState().agentStatusEpoch).toBe(epoch) + expect(normalize).toHaveBeenCalledTimes(n) + expect(views.get(0)?.progress).toBe(progress) + expect(renders.get(0)).toBe(before.get(0)! + (progress === 40 ? 1 : 2)) + for (let i = 1; i < n; i++) { + expect(renders.get(i)).toBe(before.get(i)) + } + } + mounted.unmount() + } +) + +it('keeps other hosts isolated, retains completed senders, and releases disconnected subscriptions', () => { + const create = vi.spyOn(cache, 'createWorktreeAgentRowsCache') + const baseline = readStoreListenerCount() + expect(baseline).not.toBeNull() + const other = status(9, 'remote') + useAppStore.setState({ + repos: [TEST_REPO], + worktreesByRepo: { + [TEST_REPO.id]: [ + makeWorktree({ id: 'remote', repoId: TEST_REPO.id, hostId: 'runtime:remote' }) + ] + }, + tabsByWorktree: { + ...useAppStore.getState().tabsByWorktree, + remote: [makeTab({ id: 'tab-9', worktreeId: 'remote' })] + }, + agentStatusByPaneKey: { ...useAppStore.getState().agentStatusByPaneKey, [other.paneKey]: other } + }) + const mounted = render( + <> + + + + ) + expect(readStoreListenerCount()).toBeGreaterThan(baseline!) + const leased = create.mock.results.flatMap((result) => + result.type === 'return' && result.value.byWorktree.size ? [result.value] : [] + ) + expect(leased.flatMap((value) => [...value.byWorktree.keys()]).sort()).toEqual([ + 'remote', + 'workspace' + ]) + const before = new Map(renders) + write(9, { state: 'waiting' }) + expect(views.get(9)?.dot).toBe('waiting') + expect(renders.get(9)).toBe(before.get(9)! + 1) + expect(renders.get(0)).toBe(before.get(0)) + write(0, { + agentType: 'codex', + programStatus: { state: 'blocked', progress: 60, observedAt: 2_000_000 } + }) + expect(views.get(0)).toEqual({ agentType: 'codex', dot: 'permission', progress: 60 }) + expect(renders.get(9)).toBe(before.get(9)! + 1) + for (const value of leased) { + expect(value.lastComputedWorktreeIds.length).toBeLessThanOrEqual(1) + } + const entry = { ...status(0), state: 'done' as const } + act(() => + useAppStore.setState({ + agentStatusByPaneKey: { [other.paneKey]: other }, + retainedAgentsByPaneKey: { + [entry.paneKey]: { + entry, + tab: makeTab({ id: 'tab-0', worktreeId: 'workspace' }), + worktreeId: 'workspace', + agentType: 'claude', + startedAt: 1 + } + }, + runtimeStatusByEnvironmentId: new Map([ + [ + 'remote', + runtimeEnvironmentStatusFromSnapshot({ + environmentId: 'remote', + pairingRevision: 1, + sequence: 1, + checkedAt: 1, + verification: 'blocked', + transport: 'disconnected', + retired: true, + status: null + }) + ] + ]) + }) + ) + expect(views.get(0)?.dot).toBe('done') + act(() => useAppStore.setState({ activeWorkspaceExecutionHostId: 'runtime:remote' })) + expect(views.get(0)).toEqual({}) + act(() => useAppStore.setState({ activeWorkspaceExecutionHostId: 'local' })) + expect(views.get(0)?.dot).toBe('done') + const disconnected = new Map(renders) + const constructions = create.mock.calls.length + act(() => useAppStore.setState({ sidebarBody: 'agents' })) + expect(renders).toEqual(disconnected) + expect(create).toHaveBeenCalledTimes(constructions) + mounted.unmount() + for (const value of leased) { + expect(value.byWorktree.size).toBe(0) + } + expect(readStoreListenerCount()).toBe(baseline) + render() + expect(views.get(0)?.dot).toBe('done') +}) + +it('uses the child row own glyph even when its parent program needs permission', () => { + write(0, { + programStatus: { state: 'blocked', progress: 60, observedAt: 2_000_000 }, + subagents: [{ id: 'child', state: 'working', startedAt: 2_000_000, description: 'Child' }] + }) + const state = useAppStore.getState() + const projected = cache.selectWorktreeAgentRowsCached({ + state, + worktreeId: 'workspace', + now: 2_000_000, + generation: '', + orchestration: selectors.selectRuntimeAgentOrchestrationForWorktree(state, 'workspace') + }) + const child = projected.find((row) => row.childRow)! + expect(child).toBeDefined() + const locate = () => ({ host: 'local', worktreeId: 'workspace', paneKey: child.paneKey }) + let value: AgentMessageSenderView | undefined + function Child() { + value = useAgentMessageSenderSource(locate) + return value.dot ? ( + + ) : null + } + render() + expect(value?.dot).toBe('working') + expect(value?.progress).toBeUndefined() + expect(screen.queryByRole('progressbar')).toBeNull() + expect(screen.getByLabelText('Working')).toBeDefined() +}) + +it('refuses a target-less ambiguous workspace twin while an explicit local target resolves', () => { + const workspace = `${TEST_REPO.id}::/w` + const entry = status(0, workspace) + useAppStore.setState({ + repos: [TEST_REPO], + activeWorktreeId: 'different', + activeWorkspaceExecutionHostId: 'local', + detectedWorktreesByRepo: {}, + worktreesByRepo: { + [TEST_REPO.id]: [ + makeWorktree({ id: workspace, repoId: TEST_REPO.id, hostId: 'local' }), + makeWorktree({ id: workspace, repoId: TEST_REPO.id, hostId: 'runtime:e' }) + ] + }, + tabsByWorktree: { [workspace]: [makeTab({ id: 'tab-0', worktreeId: workspace })] }, + agentStatusByPaneKey: { [entry.paneKey]: entry } + }) + const state = useAppStore.getState() + expect(getKnownExecutionHostIdForWorktree(state, workspace)).toBe('local') + expect(resolveStructuredAgentSessionOwner(state, workspace)).toBeNull() + const view = renderHook(() => useAgentMessageSenderView(from.senders[0], workspace)) + expect(view.result.current).toEqual({}) + view.unmount() + const explicit = renderHook(() => + useAgentMessageSenderView(from.senders[0], workspace, { kind: 'local' }) + ) + expect(explicit.result.current.dot).toBe('working') +}) diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageCard.tsx new file mode 100644 index 00000000000..d13036f1a5c --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageCard.tsx @@ -0,0 +1,189 @@ +import { useId, useLayoutEffect, useRef, useState } from 'react' +import { AgentStateDot } from '@/components/AgentStateDot' +import { AgentTypeIcon } from '@/lib/agent-type-icon' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { AgentMessageSource } from '../../../../shared/agent-session/agent-session-message-source' +import { + agentMessageHeading, + agentMessagePrimarySender, + agentMessageOverflows, + agentMessageFoldLabel, + agentMessageCanCopy, + AGENT_MESSAGE_FOLDED_LINES +} from '../../../../shared/agent-session/agent-message-presentation' +import { agentMessageTranscriptText } from '../../../../shared/agent-session/agent-message-display-text' +import { NativeChatAgentMessageSenders } from './NativeChatAgentMessageSenders' +import { useAgentMessageSenderView } from './native-chat-agent-message-sender-view' +import { useNativeChatVisualOwner } from './native-chat-visual-owner' +import { useNativeChatDisclosure } from './native-chat-disclosure-store' +import { NativeChatMarkdown } from './NativeChatMarkdown' +import { NativeChatCodeBlock } from './NativeChatCodeBlock' +import { NativeChatCopyButton } from './NativeChatCopyButton' +import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' +import { NativeChatImageAttachments } from './NativeChatTranscriptChrome' + +function heading(from: AgentMessageSource): string { + switch (agentMessageHeading(from)) { + case 'task': + return translate( + 'components.native-chat.agentMessage.taskFromCoordinator', + 'Task from your coordinator' + ) + case 'coordinator': + return translate( + 'components.native-chat.agentMessage.messageFromCoordinator', + 'Message from your coordinator' + ) + case 'worker': + return translate( + 'components.native-chat.agentMessage.updateFromWorker', + 'Update from your worker' + ) + case 'generic': + return translate('components.native-chat.agentMessage.messageFrom', 'Message from') + } +} + +export function NativeChatAgentMessageCard({ + message, + from, + runtimeContext, + onLinkClick, + allowFileUriLinks, + onRefolded +}: { + message: NativeChatMessage + from: AgentMessageSource + runtimeContext?: RuntimeFileOperationArgs | null + onLinkClick?: CommentMarkdownLinkClickHandler + allowFileUriLinks: boolean + onRefolded: () => void +}): React.JSX.Element { + const owner = useNativeChatVisualOwner() + const worktreeId = owner?.worktreeId ?? runtimeContext?.worktreeId ?? null + const target = owner?.target ?? runtimeContext?.target + const text = agentMessageTranscriptText(message.blocks, from) + const disclosure = useNativeChatDisclosure(`agent-message:${message.id}`, false) + const clip = useRef(null) + const clipId = useId() + const [overflows, setOverflows] = useState(false) + useLayoutEffect(() => { + const element = clip.current + if (!element) { + return + } + const measure = () => { + const lineHeight = Number.parseFloat(getComputedStyle(element).lineHeight) + setOverflows(agentMessageOverflows(element.scrollHeight, lineHeight)) + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(element) + return () => observer.disconnect() + }, [text]) + return ( +
+ +
+
+ {heading(from)} + +
+ + +
{ + if (document.hasFocus() && !event.currentTarget.contains(event.relatedTarget)) { + event.currentTarget.scrollTop = 0 + } + }} + > + +
+
+ {overflows ? ( + + ) : ( + + )} + {agentMessageCanCopy(text) ? ( + + ) : null} +
+
+
+ ) +} + +function AgentMessageAvatar({ + sender, + worktreeId, + target +}: { + sender: AgentMessageSource['senders'][number] | undefined + worktreeId: string | null + target?: RuntimeClientTarget +}): React.JSX.Element { + const view = useAgentMessageSenderView(sender, worktreeId, target) + return ( +
+ + {view.dot ? ( + + + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx index 477254b3567..bbf9729a729 100644 --- a/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx @@ -67,7 +67,9 @@ describe("another agent's message in the transcript", () => { const source = from([{ address: CODER.address, name: 'Coder' }]) renderUserMessage(source) fireEvent.click(screen.getByRole('button', { name: 'Coder' })) - expect(openAgentMessageSender).toHaveBeenCalledWith(source, source.senders[0], 'wt-chat') + expect(openAgentMessageSender).toHaveBeenCalledWith(source, source.senders[0], 'wt-chat', { + kind: 'local' + }) }) it('separates the names as the queued card does, and keeps two same-named senders two', () => { @@ -77,8 +79,9 @@ describe("another agent's message in the transcript", () => { { address: 'term_b', name: 'Codex' } ]) ) + expect(screen.getByText('Message from')).toBeInTheDocument() expect(screen.getAllByRole('button', { name: 'Codex' })).toHaveLength(2) - expect(container.textContent).toContain('Message fromCodex,Codex') + expect(container.textContent).toContain('Codex,Codex') }) it('names each sender, an unnamed one as an agent, and counts the rest', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.test.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.test.tsx index 9c2a360a86f..aef7738127e 100644 --- a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.test.tsx @@ -115,7 +115,7 @@ describe('the name a sender is shown under', () => { agentStatusByPaneKey: { [`${TAB}:${LEAF}`]: agentRow(HANDLE) } }) renderSenders(source({}, 'Recorded name')) - expect(screen.getByRole('button', { name: 'Recorded name' })).toBeInTheDocument() + expect(screen.getByText('Recorded name')).toBeInTheDocument() expect(screen.queryByText('Parser worker')).not.toBeInTheDocument() }) @@ -130,7 +130,7 @@ describe('the name a sender is shown under', () => { tabsByWorktree: { [WORKTREE]: [terminalTab({ customTitle: 'New name' })] } }) }) - expect(screen.getByRole('button', { name: 'Recorded name' })).toBeInTheDocument() + expect(screen.getByText('Recorded name')).toBeInTheDocument() expect(screen.queryByText('New name')).not.toBeInTheDocument() }) @@ -144,7 +144,7 @@ describe('the name a sender is shown under', () => { orcaSessionId: SESSION }) ) - expect(screen.getByRole('button', { name: 'Port the parser' })).toBeInTheDocument() + expect(screen.getByText('Port the parser')).toBeInTheDocument() }) it('keeps the recorded name for a chat sender whose tab has no name of its own', () => { @@ -155,7 +155,7 @@ describe('the name a sender is shown under', () => { 'Port the parser task' ) ) - expect(screen.getByRole('button', { name: 'Port the parser task' })).toBeInTheDocument() + expect(screen.getByText('Port the parser task')).toBeInTheDocument() }) it('keeps the recorded name when its agent row has no name of its own', () => { @@ -164,12 +164,12 @@ describe('the name a sender is shown under', () => { agentStatusByPaneKey: { [`${TAB}:${LEAF}`]: agentRow(HANDLE) } }) renderSenders(source({})) - expect(screen.getByRole('button', { name: 'Recorded name' })).toBeInTheDocument() + expect(screen.getByText('Recorded name')).toBeInTheDocument() }) it('keeps the recorded name once the sender is gone from this window', () => { renderSenders(source({})) - expect(screen.getByRole('button', { name: 'Recorded name' })).toBeInTheDocument() + expect(screen.getByText('Recorded name')).toBeInTheDocument() }) it('keeps the recorded name for a sender on another host, whatever runs here', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx index c959ef79413..e28d2002339 100644 --- a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx @@ -1,8 +1,10 @@ import { Fragment } from 'react' +import { ArrowUpRight } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { openAgentMessageSender } from '@/lib/open-agent-message-sender' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { agentMessageSenderCanOpen } from '../../../../shared/agent-session/agent-message-presentation' import { useNativeChatVisualOwner } from './native-chat-visual-owner' import { agentMessageSendersShown, @@ -18,34 +20,39 @@ import { export function NativeChatAgentMessageSenders({ from, chatWorktreeId, + target, queued = false }: { from: AgentMessageSource chatWorktreeId: string | null + target?: RuntimeClientTarget queued?: boolean }): React.JSX.Element { const { shown, more } = agentMessageSendersShown(from) const owner = useNativeChatVisualOwner() const worktreeId = owner?.worktreeId ?? chatWorktreeId return ( -
+
- {queued - ? translate('components.native-chat.queuedMessages.fromSender', 'From') - : translate('components.native-chat.agentMessage.messageFrom', 'Message from')} + {queued ? translate('components.native-chat.queuedMessages.fromSender', 'From') : null} - {shown.length === 0 ? {unnamedSenderLabel()} : null} + {shown.length === 0 ? ( + {unnamedSenderLabel()} + ) : null} {shown.map((sender, index) => ( {/* Pulled back over the name's padding, so it reads "A, B". */} {index < shown.length - 1 ? ( - + , ) : null} @@ -60,17 +67,19 @@ function SenderName({ from, sender, chatWorktreeId, - target + target, + queued }: { from: AgentMessageSource sender: AgentMessageSender chatWorktreeId: string | null target?: RuntimeClientTarget + queued: boolean }): React.JSX.Element { const label = useAgentMessageSenderLabel(sender, chatWorktreeId, target) - if (!chatWorktreeId || !opensFromHere(sender)) { + if (!agentMessageSenderCanOpen(sender, Boolean(chatWorktreeId)) || !chatWorktreeId) { return ( - + {label} ) @@ -78,9 +87,11 @@ function SenderName({ return ( ) } - -/** A federated sender (`dispatch:`) runs on another host, which this chat's host cannot open. */ -function opensFromHere(sender: AgentMessageSender): boolean { - return !sender.party.address.startsWith('dispatch:') -} diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageComposer.raw-task.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageComposer.raw-task.test.tsx new file mode 100644 index 00000000000..f43cbb3ad02 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageComposer.raw-task.test.tsx @@ -0,0 +1,170 @@ +// @vitest-environment happy-dom + +import { + done, + script, + SESSION, + SCOPE, + write, + writeSpy +} from './structured-queued-edit.test-fixture' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' +import { ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX } from '../../../../shared/orca-dispatch-status-prompt' +import { agentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-send-mutation' +import { projectQueuedMessageCards } from '../../../../shared/structured-agent-session-queued-cards' +import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard' +import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' +import { changePrompt, promptValue } from './native-chat-prompt-editor.test-support' +import { render } from './native-chat-tooltip-test-harness' +import { useStructuredAgentSessionQueuedEdit } from './use-structured-agent-session-queued-edit' + +vi.mock('../../store', async () => { + const { create } = await import('zustand') + return { + useAppStore: create(() => ({ + dictationState: 'idle', + settings: { voice: { enabled: false }, nativeChatSessionOptions: {} }, + agentStatusByPaneKey: {}, + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + updateSettings: vi.fn() + })) + } +}) +// Keep host discovery and OS file intake outside this text-only queued consumer contract. +vi.mock('./use-native-chat-session-options', () => ({ + useNativeChatSessionOptions: () => ({ surface: null, snapshot: [] }) +})) +vi.mock('./use-native-chat-mention-files', () => ({ + useNativeChatMentionFiles: () => ({ files: [], loading: false, failed: false }) +})) +vi.mock('./use-native-chat-skills', () => ({ + useNativeChatSkills: () => ({ status: 'ready', skills: [], error: null, retry: vi.fn() }) +})) +vi.mock('./use-native-chat-file-drops', () => ({ + useNativeChatFileDrops: () => ({ + pickAttachments: vi.fn(), + attachExternalPaths: vi.fn(), + captureExternalPaths: vi.fn(), + captureCopiedFiles: vi.fn(), + resolveAttachmentOwner: () => ({ kind: 'local' }) + }) +})) + +const transport: NativeChatStructuredComposerTransport = { + capturePresentation: () => () => true, + send: vi.fn(() => true), + dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })), + optionsSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, + optionSnapshot: [], + onError: vi.fn(), + runtime: 'local', + sessionId: SESSION, + runtimeEnvironmentId: null +} + +function QueuedTask({ message }: { message: AgentSessionQueuedMessage }) { + const messages = [message] + const cards = projectQueuedMessageCards(messages, [], {}) + const { editor, begin } = useStructuredAgentSessionQueuedEdit({ + transport: { target: { kind: 'local' }, sessionId: SESSION, capable: true, write }, + messages, + cards, + submissions: [], + composerScopeKey: SCOPE, + promptOpen: false + }) + return ( +
    + {}} + onDelete={() => {}} + onEdit={() => void begin(message.messageId)} + editor={editor ?? undefined} + editAttachTo={{ terminalTabId: 'tab', transport }} + /> +
+ ) +} + +afterEach(() => vi.restoreAllMocks()) + +it('shows the queued task and edits, saves and reopens its full raw input in the chat composer', async () => { + const prefix = `${ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX} You are a dispatched worker.\n=== TASK ===\n` + const task = 'Inspect the queued task' + const changedTask = 'Inspect the edited task' + const message: AgentSessionQueuedMessage = { + messageId: 'card', + position: 1, + state: 'waiting', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: prefix + task }], + from: { + kind: 'agent', + senders: [], + orchestration: { message: 'task', runId: 'r', taskId: 't', dispatchId: 'd' } + } + } + } + // The host's queuedMessageUpdateDecision removes attribution on a content-changing Save. + const { from: _sender, ...userBody } = message.body + const saved: AgentSessionQueuedMessage = { + ...message, + body: { ...userBody, blocks: [{ type: 'text', text: prefix + changedTask }] } + } + script.answer = done({ + status: 'updated', + messageId: message.messageId, + fingerprint: agentSessionSendBodyFingerprint(SESSION, saved.body) + }) + const view = render() + expect(screen.getByTitle(task).textContent).toBe(task) + expect(screen.getByText('From')).toBeDefined() + expect(screen.getByText('an agent')).toBeDefined() + expect(screen.queryByText(/You are working inside Orca/)).toBeNull() + + fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit message' })) + const field = await screen.findByRole('textbox', { name: 'Edit message' }) + await waitFor(() => expect(field.getAttribute('contenteditable')).toBe('true')) + expect(promptValue(field)).toBe(prefix + task) + changePrompt(field, prefix + changedTask) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(writeSpy).toHaveBeenCalledOnce()) + expect(writeSpy.mock.calls[0]?.[2]).toMatchObject({ + messageId: 'card', + text: prefix + changedTask + }) + await waitFor(() => expect(screen.queryByRole('textbox')).toBeNull()) + + view.rerender() + expect(screen.getByTitle(prefix + changedTask, { normalizer: (text) => text }).textContent).toBe( + prefix + changedTask + ) + expect(screen.queryByText('From')).toBeNull() + expect(screen.queryByText('an agent')).toBeNull() + fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit message' })) + const reopened = await screen.findByRole('textbox', { name: 'Edit message' }) + await waitFor(() => expect(reopened.getAttribute('contenteditable')).toBe('true')) + expect(promptValue(reopened)).toBe(prefix + changedTask) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByRole('textbox')).toBeNull()) + expect(writeSpy).toHaveBeenCalledOnce() + expect(transport.send).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatUserMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatUserMessageRow.tsx index 35da09032e9..cc480e4377d 100644 --- a/src/renderer/src/components/native-chat/NativeChatUserMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatUserMessageRow.tsx @@ -13,7 +13,7 @@ import { NativeChatUserMessageFold } from './NativeChatUserMessageFold' import { NativeChatCodeBlock } from './NativeChatCodeBlock' import { NativeChatCopyButton } from './NativeChatCopyButton' import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' -import { NativeChatAgentMessageSenders } from './NativeChatAgentMessageSenders' +import { NativeChatAgentMessageCard } from './NativeChatAgentMessageCard' import { NativeChatDetailsDisclosure, NativeChatImageAttachments @@ -96,62 +96,62 @@ export function NativeChatUserMessageRow({ className={cn('group relative flex flex-col gap-0.5', from ? 'items-start' : 'items-end')} > {from ? ( - - ) : null} -
- {markdown ? ( - <> + ) : ( +
+ {markdown ? ( + <> + + + + + + ) : ( - - - - - ) : ( - - )} -
+ )} +
+ )} {message.sentAs === 'goal' ? (
{translate('components.native-chat.goal.sentAsGoal', 'Sent as goal')}
) : null} - + {!from ? ( + + ) : null} {deliveryNotice?.text !== undefined ? (
{deliveryNotice.text} diff --git a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx index cb3e9525949..2533059f96f 100644 --- a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx @@ -174,7 +174,7 @@ it('uses the recipient host when identical session text is published by two host } }) render() - expect(screen.getByRole('button', { name: 'Remote sender' })).toBeInTheDocument() + expect(screen.getByText('Remote sender')).toBeInTheDocument() }) it('shows a chat sender by its current rename', () => { @@ -183,11 +183,11 @@ it('shows a chat sender by its current rename', () => { const status = feed.snapshot expect(feed.names.has(ROOT)).toBe(false) render() - expect(screen.getByRole('button', { name: 'Original sender' })).toBeInTheDocument() + expect(screen.getByText('Original sender')).toBeInTheDocument() act(() => publish('local', undefined, [{ sessionId: ROOT, conversationName: null }])) - expect(screen.getByRole('button', { name: 'Original sender' })).toBeInTheDocument() + expect(screen.getByText('Original sender')).toBeInTheDocument() act(() => publish('local', undefined, [{ sessionId: ROOT, conversationName: 'Renamed sender' }])) - expect(screen.getByRole('button', { name: 'Renamed sender' })).toBeInTheDocument() + expect(screen.getByText('Renamed sender')).toBeInTheDocument() expect(feed.snapshot).toBe(status) expect(status.get(ROOT)?.conversationName).toBeUndefined() }) @@ -211,14 +211,14 @@ it.each([false, true])( }) const from = source(true) render() - expect(screen.getByRole('button', { name: 'Original sender' })).toBeInTheDocument() + expect(screen.getByText('Original sender')).toBeInTheDocument() act(() => { useAppStore.setState({ agentStatusByPaneKey: { [PANE]: row({ key: 'session_id', id: 'provider-b' }) }, tabsByWorktree: { [SENDER]: [{ ...terminal, customTitle: 'Renamed B' }] } }) }) - const link = screen.getByRole('button', { name: 'Original sender' }) + const link = screen.getByText('Original sender') expect(link).toBeInTheDocument() fireEvent.click(link) expect(openAgentMessageSender).toHaveBeenCalledWith(from, from.senders[0], RECIPIENT) diff --git a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-index.test.tsx b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-index.test.tsx new file mode 100644 index 00000000000..e1b33d4e532 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-index.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { makeUnifiedTab } from '@/store/slices/store-test-helpers' +import { findAgentMessageSenderChatTab } from './native-chat-agent-message-sender-label' + +const initial = useAppStore.getState() +beforeEach(() => { + useAppStore.setState({ + activeWorktreeId: 'workspace', + activeWorkspaceExecutionHostId: 'local' + }) +}) +afterEach(() => { + useAppStore.setState(initial, true) + vi.restoreAllMocks() +}) + +it.each([100, 1000])( + 'shares host-qualified tab collision scans for %i distinct senders and repeated cards', + (n) => { + const tab = (id: number, host: 'local' | 'runtime:remote') => + makeUnifiedTab({ + id: `${host}-${id}`, + entityId: `session-${id}`, + worktreeId: 'workspace', + groupId: 'g', + contentType: 'agent-session', + executionHostId: host + }) + let tabVisits = 0 + const bucket = Array.from({ length: n }, (_, i) => [ + tab(i, 'local'), + tab(i, 'runtime:remote') + ]).flat() + bucket.forEach((value, i) => + Object.defineProperty(bucket, i, { + get() { + tabVisits++ + return value + } + }) + ) + useAppStore.setState({ unifiedTabsByWorktree: { workspace: bucket } }) + const state = useAppStore.getState() + for (let pass = 0; pass < 2; pass++) { + for (let i = 0; i < n; i++) { + expect(findAgentMessageSenderChatTab(state, `session-${i}`, 'local')?.id).toBe(`local-${i}`) + } + } + // Workspace/session index, per-bucket session index, and qualified collision index each walk 2N tabs once. + expect(tabVisits).toBe(n * 6) + tabVisits = 0 + useAppStore.setState({ activeWorkspaceExecutionHostId: 'runtime:remote' }) + for (let i = 0; i < n; i++) { + expect( + findAgentMessageSenderChatTab(useAppStore.getState(), `session-${i}`, 'local')?.id + ).toBe(`local-${i}`) + } + expect(tabVisits).toBe(n * 2) // Only the owner-qualified index invalidates. + } +) diff --git a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts index 12cdc995c20..a5537b5a13c 100644 --- a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts @@ -1,3 +1,4 @@ +import { agentMessageSenderLabel } from '../../../../shared/agent-session/agent-message-presentation' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' @@ -8,6 +9,7 @@ import { import { useStructuredChatTabConversationName } from '@/runtime/structured-conversation-name' import { structuredAgentSessionOwnerForTab, + structuredAgentSessionOwnerInputs, resolveStructuredAgentSessionOwner, executionHostIdForStructuredTarget } from '@/runtime/structured-agent-session-owner' @@ -20,7 +22,46 @@ export function unnamedSenderLabel(): string { return translate('components.native-chat.agentMessage.unnamedSender', 'an agent') } -function findChatTab( +const qualifiedTabs = new WeakMap< + AppState['unifiedTabsByWorktree'], + { + inputs: unknown[] + tabs: Map> + } +>() +function qualifiedSenderTab( + state: AppState, + sessionId: string, + host: string | null +): Tab | undefined { + const source = state.unifiedTabsByWorktree + const inputs = structuredAgentSessionOwnerInputs(state) + let cached = qualifiedTabs.get(source) + if (!cached || inputs.some((value, index) => value !== cached?.inputs[index])) { + const tabs = new Map>() + for (const bucket of Object.values(source)) { + for (const tab of bucket) { + if (tab.contentType !== 'agent-session') { + continue + } + const owner = structuredAgentSessionOwnerForTab(state, tab) + let byHost = tabs.get(tab.entityId) + if (!byHost) { + byHost = new Map() + tabs.set(tab.entityId, byHost) + } + if (!byHost.has(owner)) { + byHost.set(owner, tab) + } + } + } + cached = { inputs, tabs } + qualifiedTabs.set(source, cached) + } + return cached.tabs.get(sessionId)?.get(host) +} + +export function findAgentMessageSenderChatTab( state: AppState, sessionId: string, executionHostId: string | null @@ -32,14 +73,7 @@ function findChatTab( return tab } // A workspace bucket can mirror identical session ids from two hosts. - const qualified = - tab && - tabsByWorktree[worktreeId]?.find( - (candidate) => - candidate.contentType === 'agent-session' && - candidate.entityId === sessionId && - structuredAgentSessionOwnerForTab(state, candidate) === executionHostId - ) + const qualified = tab && qualifiedSenderTab(state, sessionId, executionHostId) if (qualified) { return qualified } @@ -47,6 +81,18 @@ function findChatTab( return undefined } +export function agentMessageSenderOwner( + state: AppState, + chatWorktreeId: string | null, + target?: RuntimeClientTarget +): string | null { + return target + ? executionHostIdForStructuredTarget(target) + : chatWorktreeId && getKnownExecutionHostIdForWorktree(state, chatWorktreeId) + ? resolveStructuredAgentSessionOwner(state, chatWorktreeId) + : null +} + /** Live chat names follow the sender's chat on the receiving chat's host; CLI names stay as recorded. */ export function useAgentMessageSenderLabel( sender: AgentMessageSender, @@ -55,17 +101,19 @@ export function useAgentMessageSenderLabel( ): string { const { address, orcaSessionId } = sender.party const local = !address.startsWith('dispatch:') - const owner = useAppStore((state) => - target - ? executionHostIdForStructuredTarget(target) - : chatWorktreeId && getKnownExecutionHostIdForWorktree(state, chatWorktreeId) - ? resolveStructuredAgentSessionOwner(state, chatWorktreeId) - : null - ) + const owner = useAppStore((state) => agentMessageSenderOwner(state, chatWorktreeId, target)) const chatTab = useAppStore((state) => - local && orcaSessionId ? findChatTab(state, orcaSessionId, owner) : undefined + local && owner && orcaSessionId + ? findAgentMessageSenderChatTab(state, orcaSessionId, owner) + : undefined ) const conversationName = useStructuredChatTabConversationName(chatTab) - const ownName = chatTab ? chatTab.customLabel?.trim() || conversationName : null - return ownName || sender.name || chatTab?.label.trim() || unnamedSenderLabel() + return agentMessageSenderLabel({ + localChat: Boolean(local && chatTab), + customLabel: chatTab?.customLabel, + conversationName, + snapshotName: sender.name, + tabLabel: chatTab?.label, + fallback: unnamedSenderLabel() + }) } diff --git a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-source.ts b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-source.ts new file mode 100644 index 00000000000..3247af8f2c9 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-source.ts @@ -0,0 +1,169 @@ +import { useCallback, useMemo, useSyncExternalStore } from 'react' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { + createWorktreeAgentRowsCache, + startWorktreeAgentRowsCachePass, + selectWorktreeAgentRowsCached +} from '@/components/dashboard/worktree-agent-rows-cache' +import { createWorktreeAgentFreshnessSelector } from '@/components/sidebar/worktree-agent-freshness-selector' +import { selectRuntimeAgentOrchestrationForWorktree } from '@/components/sidebar/worktree-agent-row-selectors' +import { agentRowDisplayDotState } from '@/lib/agent-row-dot-state' +import { resolveRowProgramStatus } from '@/lib/program-status/agent-row-presentation' +import { + agentMessageSenderView, + type AgentMessageSenderView +} from '../../../../shared/agent-session/agent-message-presentation' + +const EMPTY_VIEW: AgentMessageSenderView = Object.freeze({}) +export type SenderLocation = { + host: string + worktreeId: string + paneKey: string + agentType?: AgentMessageSenderView['agentType'] +} +type LocateSender = (state: AppState) => SenderLocation | undefined + +function equalView(a: AgentMessageSenderView, b: AgentMessageSenderView): boolean { + return a.agentType === b.agentType && a.dot === b.dot && a.progress === b.progress +} + +function createSource(worktreeId: string) { + const cache = createWorktreeAgentRowsCache() + const freshness = createWorktreeAgentFreshnessSelector(worktreeId) + let snapshot: AppState | undefined + let rows: ReturnType | undefined + let views = new Map() + return { + leases: 0, + dispose() { + snapshot = undefined + rows = undefined + views.clear() + cache.byWorktree.clear() + startWorktreeAgentRowsCachePass(cache) + }, + read(state: AppState, paneKey: string): AgentMessageSenderView | undefined { + if (snapshot !== state) { + snapshot = state + startWorktreeAgentRowsCachePass(cache) + const next = selectWorktreeAgentRowsCached({ + state, + worktreeId, + cache, + now: Date.now(), + generation: freshness(state), + orchestration: selectRuntimeAgentOrchestrationForWorktree(state, worktreeId) + }) + if (rows !== next) { + rows = next + const indexed = new Map() + for (const row of next) { + if (indexed.has(row.paneKey)) { + continue + } + const { presentation, dotStateOverride } = resolveRowProgramStatus(row) + const value = { + agentType: row.agentType, + dot: dotStateOverride ?? agentRowDisplayDotState(row), + progress: presentation.progress + } + const previous = views.get(row.paneKey) + indexed.set(row.paneKey, previous && equalView(previous, value) ? previous : value) + } + views = indexed + } + } + return views.get(paneKey) + } + } +} + +type Source = ReturnType +const subscribedSources = new Map() +// Uncommitted renders have no registry lease; their snapshot and derived rows can be collected. +const transientSources = new WeakMap>() +function sourceKey(location: SenderLocation): string { + return JSON.stringify([location.host, location.worktreeId]) +} +function readSource(state: AppState, location: SenderLocation): Source { + const key = sourceKey(location) + const subscribed = subscribedSources.get(key) + if (subscribed) { + return subscribed + } + let sources = transientSources.get(state) + if (!sources) { + sources = new Map() + transientSources.set(state, sources) + } + let source = sources.get(key) + if (!source) { + source = createSource(location.worktreeId) + sources.set(key, source) + } + return source +} + +/** Share the sidebar projection before notifying cards; only subscription leases retain a source. */ +export function useAgentMessageSenderSource(locate: LocateSender): AgentMessageSenderView { + const selected = useMemo(() => { + let previous = EMPTY_VIEW + return (state: AppState) => { + const location = locate(state) + const next = location + ? agentMessageSenderView( + readSource(state, location).read(state, location.paneKey), + location.agentType + ) + : EMPTY_VIEW + if (!equalView(previous, next)) { + previous = next + } + return previous + } + }, [locate]) + const subscribe = useCallback( + (notify: () => void) => { + let lease: { key: string; source: Source } | undefined + const release = () => { + if (lease && --lease.source.leases === 0) { + subscribedSources.delete(lease.key) + lease.source.dispose() + } + lease = undefined + } + const update = () => { + const state = useAppStore.getState() + const location = locate(state) + const key = location ? sourceKey(location) : undefined + if (lease?.key !== key) { + release() + if (location && key) { + const source = readSource(state, location) + transientSources.get(state)?.delete(key) + source.leases++ + subscribedSources.set(key, source) + lease = { key, source } + } + } + return selected(state) + } + let value = update() + const unsubscribe = useAppStore.subscribe(() => { + const next = update() + if (next !== value) { + value = next + notify() + } + }) + return () => { + unsubscribe() + release() + } + }, + [locate, selected] + ) + const getSnapshot = useCallback(() => selected(useAppStore.getState()), [selected]) + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +} diff --git a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-view.ts b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-view.ts new file mode 100644 index 00000000000..b7c2c5fea72 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-view.ts @@ -0,0 +1,107 @@ +import { useCallback, useMemo } from 'react' +import { + useAgentMessageSenderSource, + type SenderLocation +} from './native-chat-agent-message-sender-source' +import type { AppState } from '@/store/types' +import { getKnownExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { structuredAgentSessionOwnerInputs } from '@/runtime/structured-agent-session-owner' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import type { AgentMessageSender } from '../../../../shared/agent-session/agent-session-message-source' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { + agentMessageSenderOwner, + findAgentMessageSenderChatTab +} from './native-chat-agent-message-sender-label' + +const handleIndexes = new WeakMap< + object, + { inputs: unknown[]; index: Map> } +>() +function byHandle( + state: AppState, + source: AppState['agentStatusByPaneKey'] | AppState['retainedAgentsByPaneKey'] +) { + const inputs = structuredAgentSessionOwnerInputs(state) + let cached = handleIndexes.get(source) + if (!cached || inputs.some((value, index) => value !== cached?.inputs[index])) { + const index = new Map>() + const owners = new Map() + for (const value of Object.values(source)) { + const entry = 'entry' in value ? value.entry : value + if (!entry.terminalHandle || !entry.worktreeId) { + continue + } + if (!owners.has(entry.worktreeId)) { + owners.set(entry.worktreeId, getKnownExecutionHostIdForWorktree(state, entry.worktreeId)) + } + const owner = owners.get(entry.worktreeId) ?? null + let byHost = index.get(entry.terminalHandle) + if (!byHost) { + byHost = new Map() + index.set(entry.terminalHandle, byHost) + } + const entries = byHost.get(owner) ?? [] + entries.push(entry) + byHost.set(owner, entries) + } + cached = { inputs, index } + handleIndexes.set(source, cached) + } + return cached.index +} + +export function useAgentMessageSenderView( + sender: AgentMessageSender | undefined, + chatWorktreeId: string | null, + target?: RuntimeClientTarget +) { + const address = sender?.party.address + const sessionId = sender?.party.orcaSessionId + const handle = sender?.party.terminalHandle + const targetKind = target?.kind + const environmentId = target?.kind === 'environment' ? target.environmentId : undefined + const stableTarget = useMemo( + () => + targetKind === 'local' + ? { kind: 'local' } + : environmentId !== undefined + ? { kind: 'environment', environmentId } + : undefined, + [targetKind, environmentId] + ) + const locate = useCallback( + (state: AppState): SenderLocation | undefined => { + if (!address || address.startsWith('dispatch:')) { + return undefined + } + const owner = agentMessageSenderOwner(state, chatWorktreeId, stableTarget) + if (!owner) { + return undefined + } + const chatTab = sessionId ? findAgentMessageSenderChatTab(state, sessionId, owner) : undefined + let paneKey = chatTab && structuredAgentSessionPaneKey(chatTab.id, chatTab.entityId) + if (!paneKey && handle) { + for (const source of [state.agentStatusByPaneKey, state.retainedAgentsByPaneKey]) { + const matches = byHandle(state, source).get(handle)?.get(owner) ?? [] + if (matches.length) { + paneKey = matches.length === 1 ? matches[0].paneKey : undefined + break + } + } + } + const worktreeId = + chatTab?.worktreeId ?? + (paneKey + ? (state.agentStatusByPaneKey[paneKey]?.worktreeId ?? + state.retainedAgentsByPaneKey[paneKey]?.worktreeId) + : undefined) + return worktreeId && paneKey + ? { host: owner, worktreeId, paneKey, agentType: chatTab?.agentSessionAgent } + : undefined + }, + [address, sessionId, handle, chatWorktreeId, stableTarget] + ) + return useAgentMessageSenderSource(locate) +} diff --git a/src/renderer/src/components/native-chat/native-chat-message-copy-text.ts b/src/renderer/src/components/native-chat/native-chat-message-copy-text.ts index 61cc74ba605..358fd68e3ad 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-copy-text.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-copy-text.ts @@ -1,3 +1,4 @@ +import { agentMessageTranscriptText } from '../../../../shared/agent-session/agent-message-display-text' import { nativeChatMessageCopyText } from '../../../../shared/native-chat-message-copy-text' import { nativeChatStoppedBeforeStartLabel } from './native-chat-stopped-before-start-row' import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' @@ -9,6 +10,8 @@ export function nativeChatSlotCopyText( ): string | null { const slot = slots.find((entry) => entry.kind === 'message' && entry.message.id === messageId) return slot?.kind === 'message' - ? nativeChatMessageCopyText(slot.message, nativeChatStoppedBeforeStartLabel()) + ? slot.message.from && slot.message.role === 'user' + ? agentMessageTranscriptText(slot.message.blocks, slot.message.from) + : nativeChatMessageCopyText(slot.message, nativeChatStoppedBeforeStartLabel()) : null } diff --git a/src/renderer/src/components/native-chat/structured-queued-edit.test-fixture.tsx b/src/renderer/src/components/native-chat/structured-queued-edit.test-fixture.tsx index 0ab3102eaf7..326fd9c807a 100644 --- a/src/renderer/src/components/native-chat/structured-queued-edit.test-fixture.tsx +++ b/src/renderer/src/components/native-chat/structured-queued-edit.test-fixture.tsx @@ -7,6 +7,9 @@ import { queuedMessageWithEdit } from '../../../../shared/queued-message-edit' import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, vi } from 'vitest' import { toast } from 'sonner' +import type * as SonnerModule from 'sonner' +import type * as StructuredSessionClient from '@/runtime/structured-agent-session-client' +import type * as RendererI18n from '@/i18n/i18n' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage, @@ -44,11 +47,28 @@ const hoistedRpc = vi.hoisted(() => ) ) export const rpc = hoistedRpc -vi.mock('@/runtime/structured-agent-session-client', () => ({ +const toastError = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/structured-agent-session-client', async (importOriginal) => ({ + ...(await importOriginal()), callStructuredAgentSession: hoistedRpc })) -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + toast: Object.assign( + function (this: unknown, ...args: Parameters) { + return original.toast.apply(this, args) + }, + original.toast, + { error: toastError } + ) + } +}) +vi.mock('@/i18n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + translate: (_key: string, fallback: string) => fallback +})) export const SESSION = 'folder-session' export const SCOPE = 'pane-draft' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a1a0d2feb40..667ba639a2f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19730,7 +19730,12 @@ }, "agentMessage": { "messageFrom": "Message from", - "unnamedSender": "an agent" + "unnamedSender": "an agent", + "goToAgent": "Go to agent", + "messageFromCoordinator": "Message from your coordinator", + "showMore": "Show more", + "taskFromCoordinator": "Task from your coordinator", + "updateFromWorker": "Update from your worker" }, "goal": { "placeholder": "Describe your goal, define measurable outcomes for best results", diff --git a/src/shared/agent-session/agent-message-display-text.test.ts b/src/shared/agent-session/agent-message-display-text.test.ts new file mode 100644 index 00000000000..a7d0e3fbbea --- /dev/null +++ b/src/shared/agent-session/agent-message-display-text.test.ts @@ -0,0 +1,70 @@ +import { expect, it, vi } from 'vitest' +import { agentMessageDisplayText, agentMessageTranscriptText } from './agent-message-display-text' +import type { AgentMessageSource } from './agent-session-message-source' +import type { NativeChatBlock } from '../native-chat-types' +import * as grammar from '../orca-dispatch-status-prompt' + +const task: AgentMessageSource = { + kind: 'agent', + senders: [], + orchestration: { message: 'task', runId: 'r', taskId: 't', dispatchId: 'd' } +} +const prefix = `${grammar.ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX}\r\nInstructions\r\n=== TASK ===\r\n` + +it('keeps exact task suffixes and canonical blocks, with paragraph fallback for ineligible inputs', () => { + for (const suffix of ['', ' Task πŸ‹\r\n\r\n=== TASK ===\ntrailing ']) { + const blocks: NativeChatBlock[] = [ + { type: 'text', text: prefix + suffix }, + { type: 'image-ref', path: '/image.png' } + ] + const original = structuredClone(blocks) + expect(agentMessageDisplayText(blocks, task)).toBe(suffix) + expect(agentMessageTranscriptText(blocks, task)).toBe(suffix) + expect(agentMessageDisplayText(blocks)).toBeUndefined() + expect(blocks).toEqual(original) + } + for (const blocks of [ + [{ type: 'text', text: 'ordinary prose' }], + [ + { + type: 'text', + text: `${grammar.ORCA_DISPATCH_STATUS_PREAMBLE_PREFIX}\n${'x'.repeat(24_576)}\n=== TASK ===\nlate` + } + ] + ] satisfies NativeChatBlock[][]) { + expect(agentMessageDisplayText(blocks, task)).toBeUndefined() + expect(agentMessageTranscriptText(blocks, task)).toBe( + blocks.map((block) => (block.type === 'text' ? block.text : '')).join('\n\n') + ) + } +}) + +it.each([100, 1000])( + 'parses %i real task inputs once and invalidates immutable edited blocks', + (n) => { + const parse = vi.spyOn(grammar, 'orcaDispatchTaskBodyOffset') + const suffix = 'task '.repeat(6000) + const inputs = Array.from({ length: n }, (): NativeChatBlock[] => [ + { type: 'text', text: prefix + suffix } + ]) + try { + for (const blocks of inputs) { + expect(agentMessageDisplayText(blocks, task)).toBe(suffix) + } + expect(parse).toHaveBeenCalledTimes(n) + parse.mockClear() + for (const blocks of inputs) { + expect(agentMessageDisplayText(blocks, task)).toBe(suffix) + } + expect(parse).not.toHaveBeenCalled() + for (const _blocks of inputs) { + expect( + agentMessageDisplayText([{ type: 'text', text: `${prefix}${suffix}edited` }], task) + ).toBe(`${suffix}edited`) + } + expect(parse).toHaveBeenCalledTimes(n) + } finally { + parse.mockRestore() + } + } +) diff --git a/src/shared/agent-session/agent-message-display-text.ts b/src/shared/agent-session/agent-message-display-text.ts new file mode 100644 index 00000000000..c6b0eee7e09 --- /dev/null +++ b/src/shared/agent-session/agent-message-display-text.ts @@ -0,0 +1,36 @@ +import type { AgentMessageSource } from './agent-session-message-source' +import type { NativeChatBlock } from '../native-chat-types' +import { nativeChatProseToMarkdown } from '../native-chat/transcript/native-chat-prose' +import { orcaDispatchTaskBodyOffset } from '../orca-dispatch-status-prompt' + +const taskBodies = new WeakMap() + +/** Display only: providers, queue editors and context readers retain the original input. */ +export function agentMessageDisplayText( + blocks: readonly NativeChatBlock[], + from?: AgentMessageSource +): string | undefined { + if (from?.orchestration?.message !== 'task') { + return undefined + } + if (taskBodies.has(blocks)) { + return taskBodies.get(blocks) + } + if (blocks.some((block) => block.type !== 'text' && block.type !== 'image-ref')) { + taskBodies.set(blocks, undefined) + return undefined + } + const texts = blocks.filter((block) => block.type === 'text') + const text = texts.length === 1 ? texts[0].text : undefined + const offset = text === undefined ? undefined : orcaDispatchTaskBodyOffset(text) + const body = offset === undefined ? undefined : text?.slice(offset) + taskBodies.set(blocks, body) + return body +} + +export function agentMessageTranscriptText( + blocks: NativeChatBlock[], + from?: AgentMessageSource +): string { + return agentMessageDisplayText(blocks, from) ?? nativeChatProseToMarkdown(blocks) +} diff --git a/src/shared/agent-session/agent-message-presentation.ts b/src/shared/agent-session/agent-message-presentation.ts new file mode 100644 index 00000000000..1aec05070c3 --- /dev/null +++ b/src/shared/agent-session/agent-message-presentation.ts @@ -0,0 +1,84 @@ +import type { AgentMessageSender, AgentMessageSource } from './agent-session-message-source' +import type { AgentStatusEntry } from '../agent-status-types' + +export const AGENT_MESSAGE_FOLDED_LINES = 3 +export type AgentMessageHeading = 'task' | 'coordinator' | 'worker' | 'generic' +export type AgentMessageSenderView = Readonly<{ + agentType?: AgentStatusEntry['agentType'] + progress?: number + dot?: + | 'working' + | 'monitoring' + | 'blocked' + | 'waiting' + | 'interrupted' + | 'failed' + | 'done' + | 'idle' + | 'unverifiable' + | 'unconfirmed' + | 'permission' +}> + +export function agentMessageHeading(from: AgentMessageSource): AgentMessageHeading { + if (from.orchestration?.message === 'task') { + return 'task' + } + if (from.orchestration?.message === 'mail-notice' && from.senders.length > 0) { + if (from.senders.every((sender) => sender.relation === 'coordinator')) { + return 'coordinator' + } + if (from.senders.every((sender) => sender.relation === 'worker')) { + return 'worker' + } + } + return 'generic' +} + +export function agentMessageSenderLabel(facts: { + localChat: boolean + customLabel?: string | null + conversationName?: string | null + snapshotName?: string | null + tabLabel?: string | null + fallback: string +}): string { + return ( + (facts.localChat && (facts.customLabel?.trim() || facts.conversationName)) || + facts.snapshotName || + facts.tabLabel?.trim() || + facts.fallback + ) +} + +export function agentMessageSenderCanOpen( + sender: AgentMessageSender, + hasWorkspace: boolean +): boolean { + return hasWorkspace && !sender.party.address.startsWith('dispatch:') +} + +export function agentMessagePrimarySender( + from: AgentMessageSource +): AgentMessageSender | undefined { + return from.senders[0] +} + +export function agentMessageSenderView( + row: AgentMessageSenderView | undefined, + chatType: AgentMessageSenderView['agentType'] +): AgentMessageSenderView { + return row ?? { agentType: chatType } +} + +export function agentMessageOverflows(height: number, lineHeight: number): boolean { + return height > lineHeight * AGENT_MESSAGE_FOLDED_LINES + 1 +} + +export function agentMessageFoldLabel(open: boolean): 'expanded' | 'collapsed' { + return open ? 'expanded' : 'collapsed' +} + +export function agentMessageCanCopy(text: string): boolean { + return text.length > 0 +} diff --git a/src/shared/agent-session/agent-session-message-source.test.ts b/src/shared/agent-session/agent-session-message-source.test.ts index 9548bc33753..0a4f548714f 100644 --- a/src/shared/agent-session/agent-session-message-source.test.ts +++ b/src/shared/agent-session/agent-session-message-source.test.ts @@ -44,6 +44,11 @@ describe("a message's sender, read through its one reader", () => { orchestration: { message: 'task', runId: 'r1', taskId: 't1', dispatchId: 'ctx_1' } } expect(readAgentMessageSource(structuredClone(task))).toEqual(task) + const parsed = readAgentMessageSource({ + ...task, + senders: FROM.senders.map((sender) => ({ ...sender, relation: 'future' })) + }) + expect(parsed).toEqual(task) }) it('keeps an agent an agent when its orchestration kind is newer than this build', () => { diff --git a/src/shared/agent-session/agent-session-message-source.ts b/src/shared/agent-session/agent-session-message-source.ts index 1bdeafaf74e..4e44cef0592 100644 --- a/src/shared/agent-session/agent-session-message-source.ts +++ b/src/shared/agent-session/agent-session-message-source.ts @@ -11,6 +11,7 @@ export type AgentMessageSender = Readonly<{ party: Omit /** What Orca called the sender when it wrote, kept for when it is gone; null when it had none. */ name: string | null + relation?: 'worker' | 'coordinator' | 'none' }> /** One orchestration message a notice points at: its record, and its sender's `senders` address. */ @@ -80,6 +81,7 @@ const senderSchema = z.object({ terminalHandle: z.string().nullable(), orcaSessionId: orcaSessionIdSchema.nullable() }), + relation: salvagedOptional('relation', z.enum(['worker', 'coordinator', 'none'])), name: z.unknown().transform(agentMessageSenderName) }) diff --git a/src/shared/orca-dispatch-status-prompt.ts b/src/shared/orca-dispatch-status-prompt.ts index b7b1d402ecd..029e9fc3be8 100644 --- a/src/shared/orca-dispatch-status-prompt.ts +++ b/src/shared/orca-dispatch-status-prompt.ts @@ -119,6 +119,36 @@ export function findOrcaDispatchTaskMarkerIndex(value: string): number { : value.indexOf(ORCA_DISPATCH_STATUS_TASK_MARKER) } +/** Offset into the original task input; detection is bounded, the authored suffix is not. */ +export function orcaDispatchTaskBodyOffset(value: string): number | undefined { + const start = findOrcaDispatchPreambleStart(value) + if (start === -1) { + return undefined + } + const scanEnd = Math.min(value.length, ORCA_DISPATCH_STATUS_SOURCE_SCAN_LIMIT) + const scan = value.slice(start, scanEnd) + const marker = findOrcaDispatchTaskMarkerIndex(scan) + if (marker === -1) { + return undefined + } + let offset = start + marker + ORCA_DISPATCH_STATUS_TASK_MARKER.length + // A marker at the scan bound must still end its original delimiter line. + if ( + offset < value.length && + !isLineBreak(value.charCodeAt(offset)) && + (scan.includes('\n') || scan.includes('\r')) + ) { + return undefined + } + if (value.charCodeAt(offset) === 13) { + offset++ + } + if (value.charCodeAt(offset) === 10) { + offset++ + } + return offset +} + function isLineBreak(code: number): boolean { return code === 10 || code === 13 } diff --git a/src/shared/structured-agent-session-queued-cards.ts b/src/shared/structured-agent-session-queued-cards.ts index 3158fc13a6c..0ead2a2773f 100644 --- a/src/shared/structured-agent-session-queued-cards.ts +++ b/src/shared/structured-agent-session-queued-cards.ts @@ -1,5 +1,6 @@ // Derive hold labels on the client; the wire carries queue state, not presentation wording. +import { agentMessageDisplayText } from './agent-session/agent-message-display-text' import { readWholeAgentSessionFailureFact, type AgentSessionFailureFact, @@ -44,6 +45,7 @@ export type QueuedMessageCard = { messageId: string position: number text: string + displayText?: string /** The draft's images, which an edit of its text keeps. */ images?: readonly NativeChatImageRefBlock[] state: 'waiting' | 'returned' @@ -100,6 +102,13 @@ export function projectQueuedMessageCards( messageId: message.messageId, position: message.position, text: queuedMessageCardText(message.body), + ...(from + ? { + displayText: + agentMessageDisplayText(message.body.blocks, from) ?? + queuedMessageCardText(message.body) + } + : {}), ...queuedMessageCardImages(message.body), state: message.state, hold, @@ -171,12 +180,13 @@ export function newestSteerableQueuedMessageCard( /** The card's line: its text, or for a card of images alone, the image's name or their count. */ export function queuedMessageCardLine( - card: Pick, + card: Pick, copy: NativeChatImageLabelCopy = NATIVE_CHAT_IMAGE_LABEL_ENGLISH ): string { const images = card.images ?? [] - if (card.text.trim() || images.length === 0) { - return card.text + const text = card.displayText ?? card.text + if (text.trim() || images.length === 0) { + return text } return images.length === 1 ? nativeChatImageLabel(images[0], copy)