From 8135e8875e5ca5260333dcf906707ec70c824960 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:18:30 -0700 Subject: [PATCH 1/6] feat(native-chat): show which agent a chat message is from A message another agent sends into a native chat (today the orchestration mail notice) looked exactly like the person's own: a right-hand bubble and a queued card identical to theirs. The sender was recorded only on the queue row, so a message sent to an idle chat, or a card once it was sent, had no sender at all. The sender now lives on the message itself (`from` on the message body): - The send path carries it on both deliveries; an idle chat records it too, and it survives the queue, /clear's carry, restart and history. - No fingerprint covers it: one shared payload projection leaves it out at every fingerprint site, so stored digests are unchanged and the provider's echo still folds into the same bubble. The provider never receives it. - A client cannot set it: its send params stay strict. - One reader normalizes it where it is read (journal load, queued row, client projection); a newer message kind keeps the sender an agent's. - Each sender keeps a name snapshot from names Orca controls: a chat's conversation name or agent label, a terminal's own tab title or agent, a federated sender's task name. Never an agent-set terminal title. - The separate queued-row sender column and its plumbing are removed; it never shipped. Desktop shows such a message as a left-aligned "Message from " row; the name opens the sender: its chat through the existing open-chat flow (after a host lookup that follows /clear and dispatch addresses), or its terminal through the terminal-link path, with the existing feedback when it is gone. A queued card shows a plain "From " line. Mobile shows the same attribution, not yet clickable. --- .../session/MobileNativeChatMessage.test.ts | 25 +++ .../src/session/MobileNativeChatMessage.tsx | 17 +- .../MobileNativeChatQueuedMessages.test.tsx | 1 + .../MobileNativeChatQueuedMessages.tsx | 5 + .../mobile-agent-message-attribution.test.ts | 50 ++++++ .../mobile-agent-message-attribution.ts | 21 +++ .../mobile-native-chat-message-styles.ts | 9 ++ ...le-structured-queued-message-cards.test.ts | 3 +- .../mobile-structured-queued-message-cards.ts | 7 +- .../journal-queued-messages.ts | 2 - .../journal-reducer.test.ts | 30 ++++ .../agent-session-journal/journal-reducer.ts | 8 +- .../journal-row-unusable-annotations.ts | 18 +++ .../journal-submission-queued-link.test.ts | 6 +- ...queued-message-bookkeeping-failure.test.ts | 6 +- .../queued-message-delivered-echo.test.ts | 9 +- .../queued-message-delivered-echo.ts | 8 +- .../queued-message-pause.test.ts | 4 +- .../queued-message-schema.ts | 4 +- .../queued-message-store.test.ts | 56 ++++--- .../queued-message-table.ts | 34 +--- ...structured-agent-session-host-mutations.ts | 4 - ...structured-agent-session-mutation-plans.ts | 23 ++- ...session-queued-message-rig.test-fixture.ts | 15 +- ...ured-agent-session-queued-messages.test.ts | 30 ++-- ...tructured-agent-session-queued-messages.ts | 30 +--- ...ructured-agent-session-queued-mutations.ts | 11 +- .../structured-agent-session-queued-send.ts | 3 - ...ent-session-restore-without-import.test.ts | 3 +- .../orca-runtime-stop-requested-pty-ids.ts | 26 ++++ .../orchestration/agent-facing-parity.test.ts | 1 + .../orchestration-party-location.ts | 62 ++++++++ .../orchestration/orchestration-party.ts | 18 ++- .../orchestration-sender-location.test.ts | 133 ++++++++++++++++ .../orchestration-sender-name.ts | 48 ++++++ .../send-agent-turn-host.test.ts | 43 ++++-- .../orchestration/send-agent-turn.test.ts | 33 ++-- .../runtime/orchestration/send-agent-turn.ts | 15 +- .../structured-mail-source.test.ts | 29 +++- .../orchestration/structured-mail-source.ts | 26 +++- ...tructured-mailbox-pointer-delivery.test.ts | 6 +- .../structured-mailbox-pointer-delivery.ts | 24 +-- .../structured-mailbox-pointer-host.test.ts | 7 +- .../structured-mailbox-pointer-host.ts | 1 - .../rpc/methods/orchestration/caller-show.ts | 20 ++- .../orchestration-party-addressing.test.ts | 26 +++- .../rpc/orchestration-session-caller.test.ts | 15 +- ...ctured-chat-coordinator-mail-queue.test.ts | 17 +- .../NativeChatAgentMessageRow.test.tsx | 127 +++++++++++++++ .../NativeChatAgentMessageSenders.tsx | 48 ++++++ .../native-chat/NativeChatMessageRow.tsx | 24 ++- .../NativeChatQueuedMessageCard.tsx | 6 + .../native-chat-agent-message-sender-label.ts | 24 +++ ...uctured-agent-session-queued-cards.test.ts | 22 +++ .../structured-agent-session-queued-cards.ts | 10 +- .../terminal-pane/stale-agent-row.ts | 27 ++-- .../terminal-pane/terminal-handle-links.ts | 2 +- src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/fr.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- .../activate-ai-vault-structured-session.ts | 17 +- .../src/lib/open-agent-message-sender.test.ts | 146 ++++++++++++++++++ .../src/lib/open-agent-message-sender.ts | 125 +++++++++++++++ src/shared/agent-session-journal-types.ts | 4 + .../agent-session-message-source.test.ts | 97 ++++++++++++ src/shared/agent-session-message-source.ts | 107 ++++++++----- src/shared/native-chat-types.ts | 3 + src/shared/orchestration-caller-status.ts | 8 + .../rpc-contract/orchestration-params.ts | 5 + .../rpc-params-catalog.generated.ts | 2 + ...ed-agent-session-projection-sender.test.ts | 43 ++++++ .../structured-agent-session-projection.ts | 6 +- ...ctured-agent-session-send-mutation.test.ts | 74 +++++++++ .../structured-agent-session-send-mutation.ts | 28 +++- 77 files changed, 1681 insertions(+), 308 deletions(-) create mode 100644 mobile/src/session/mobile-agent-message-attribution.test.ts create mode 100644 mobile/src/session/mobile-agent-message-attribution.ts create mode 100644 src/main/runtime/orchestration/orchestration-party-location.ts create mode 100644 src/main/runtime/orchestration/orchestration-sender-location.test.ts create mode 100644 src/main/runtime/orchestration/orchestration-sender-name.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts create mode 100644 src/renderer/src/lib/open-agent-message-sender.test.ts create mode 100644 src/renderer/src/lib/open-agent-message-sender.ts create mode 100644 src/shared/agent-session-message-source.test.ts create mode 100644 src/shared/structured-agent-session-projection-sender.test.ts create mode 100644 src/shared/structured-agent-session-send-mutation.test.ts diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 135469d89171..7bbaeb81eb2b 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -5,6 +5,7 @@ import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-sum import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows' import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-native-chat-message-styles' vi.mock('react-native', async () => { const React = await import('react') @@ -113,6 +114,30 @@ describe('MobileNativeChatMessage', () => { } ) + it("names another agent's message and sets it apart from the person's bubble", () => { + const agentMessage: NativeChatMessage = { + ...userMessage([{ type: 'text', text: 'You have 1 orchestration message.' }]), + from: { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: null + } + } + const tree = render(agentMessage) + expect(textIn(tree.root)[0]).toBe('Message from Coder') + // Left-aligned agent prose, not the person's inverted bubble. + expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(1) + const rows = tree.root.findAll( + (node) => String(node.type) === 'View' && Array.isArray(node.props.style) + ) + expect(rows.some((row) => row.props.style.includes(styles.rowUser))).toBe(false) + }) + it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => { const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }])) expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe( diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 83f93266d6ff..bb7af2c5c42d 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -17,6 +17,7 @@ import { ToolRun } from './MobileNativeChatToolRun' import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status' import { isRenderableImageUri } from './mobile-native-chat-image-preview' import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles' +import { agentMessageAttribution } from './mobile-agent-message-attribution' function Prose({ block, @@ -140,7 +141,9 @@ function MobileNativeChatMessageImpl({ /** Structured lane only: live tool progress plus the turn-status disclosure. */ structuredActivityUi?: boolean }): React.JSX.Element { - const isUser = message.role === 'user' + // Another agent's message is set apart from the person's bubble, left-aligned and named. + const attribution = agentMessageAttribution('Message from', message.from) + const isUser = message.role === 'user' && attribution === null const isReasoning = message.role === 'reasoning' // Separate the agent's words from its tool activity: prose renders first, the // tool calls fold into a collapsible run beneath. The user's own messages get @@ -182,8 +185,18 @@ function MobileNativeChatMessageImpl({ + {attribution !== null ? ( + + {attribution} + + ) : null} {prose.map((block, index) => ( )} + {card.attribution ? ( + + {card.attribution} + + ) : null} {/* Two lines, not the desktop's one: the phone row has no hover title to read the rest. */} {card.text} diff --git a/mobile/src/session/mobile-agent-message-attribution.test.ts b/mobile/src/session/mobile-agent-message-attribution.test.ts new file mode 100644 index 000000000000..1e9d805ca23a --- /dev/null +++ b/mobile/src/session/mobile-agent-message-attribution.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import type { AgentMessageSource } from '../../../src/shared/agent-session-message-source' +import { agentMessageAttribution } from './mobile-agent-message-attribution' +import { mobileQueuedMessageCards } from './mobile-structured-queued-message-cards' + +function from(names: (string | null)[]): AgentMessageSource { + return { + kind: 'agent', + senders: names.map((name, index) => ({ + party: { address: `term_${index}`, terminalHandle: `term_${index}`, orcaSessionId: null }, + name + })), + orchestration: null + } +} + +describe('who another agent’s message is from, on mobile', () => { + it('names nothing on the person’s own message', () => { + expect(agentMessageAttribution('From', undefined)).toBeNull() + }) + + it('names each sender once, an unnamed one as an agent, and counts the rest', () => { + expect(agentMessageAttribution('Message from', from(['Coder']))).toBe('Message from Coder') + expect(agentMessageAttribution('From', from([null, null]))).toBe('From an agent') + expect(agentMessageAttribution('From', from([]))).toBe('From an agent') + expect(agentMessageAttribution('From', from(['A', 'B', 'C', 'D']))).toBe('From A, B, C +1') + }) + + it('puts the line on the queued card, read through the shared reader', () => { + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'You have 1 orchestration message.' }] + } + const cards = mobileQueuedMessageCards( + [ + { + messageId: 'agent', + position: 1, + state: 'waiting', + body: { ...body, from: from(['Coder']) } + }, + { messageId: 'person', position: 2, state: 'waiting', body } + ], + [], + { pendingPrompt: false } + ) + expect(cards.map((card) => card.attribution)).toEqual(['From Coder', null]) + }) +}) diff --git a/mobile/src/session/mobile-agent-message-attribution.ts b/mobile/src/session/mobile-agent-message-attribution.ts new file mode 100644 index 000000000000..52037b3f59dc --- /dev/null +++ b/mobile/src/session/mobile-agent-message-attribution.ts @@ -0,0 +1,21 @@ +// The plain line naming who sent another agent's message; not a link on mobile yet. + +import { + agentMessageSendersShown, + type AgentMessageSource +} from '../../../src/shared/agent-session-message-source' + +const UNNAMED_SENDER = 'an agent' + +/** Null for the person's own message. */ +export function agentMessageAttribution( + lead: 'From' | 'Message from', + source: AgentMessageSource | undefined +): string | null { + if (!source) { + return null + } + const { shown, more } = agentMessageSendersShown(source) + const names = [...new Set(shown.map((sender) => sender.name ?? UNNAMED_SENDER))] + return `${lead} ${names.length > 0 ? names.join(', ') : UNNAMED_SENDER}${more > 0 ? ` +${more}` : ''}` +} diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index 225d664e65e4..62fe2909214d 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -37,6 +37,15 @@ export const styles = StyleSheet.create({ reasoning: { opacity: 0.7 }, + agentMessage: { + paddingLeft: spacing.md, + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle + }, + agentAttribution: { + color: colors.textMuted, + fontSize: typography.metaSize + }, toolRun: { marginTop: spacing.xs }, diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 44d1e0b9729f..644ca1d6455c 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -45,7 +45,8 @@ describe('mobileQueuedMessageCards', () => { state: 'waiting', paused: false, needsAttention: false, - caption: null + caption: null, + attribution: null }) }) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index f3edb13e53c8..93631a44c62a 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -12,6 +12,8 @@ import { type AgentSessionQueuedMessage, type AgentSessionQueuePause } from '../../../src/shared/agent-session-wire' +import { readAgentMessageSource } from '../../../src/shared/agent-session-message-source' +import { agentMessageAttribution } from './mobile-agent-message-attribution' export type MobileQueuedMessageCard = { messageId: string @@ -23,6 +25,8 @@ export type MobileQueuedMessageCard = { needsAttention: boolean /** Status under the text; null for a card plainly waiting its turn, the paused queue's too. */ caption: string | null + /** "From " on another agent's card; null on the person's. */ + attribution: string | null } function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string { @@ -131,7 +135,8 @@ export function mobileQueuedMessageCards( needsAttention: draft.state === 'returned' || (paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED), - caption + caption, + attribution: agentMessageAttribution('From', readAgentMessageSource(draft.body.from)) }) if (draft.state === 'returned') { behindReturned = true diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 8c190196bdfa..0828740ca6f1 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -33,7 +33,6 @@ import { type QueuedMessageHoldReason, type QueuedMessageRow } from './queued-message-table' -import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' import { @@ -111,7 +110,6 @@ export class JournalQueuedMessages { fingerprint: string hostInstance: string carriedFrom?: string - source: AgentSessionMessageSource }, receipt?: JournalOperationReceipt ): Promise { diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index 60d068ebfd8c..d71aa7d97576 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -11,6 +11,7 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { applyJournalRow, createJournalReducerState, @@ -451,6 +452,35 @@ describe('submission and dispatch state machine', () => { ]) }) + it("folds the echo of another agent's message into its bubble, which keeps the sender", () => { + const echoed = userText('You have 1 orchestration message.') + const from = { + kind: 'agent' as const, + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: null + } + const state = fold([ + { + kind: 'submission', + clientMessageId: 'agent-send', + // Stored as the send path stores it: the sender is outside the fingerprint. + payloadFingerprint: agentSessionSendBodyFingerprint('session-1', { ...echoed, from }), + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { ...echoed, from }, + ...base(1) + }, + { kind: 'item', itemId: 'codex:thread-1:turn-1:0', revision: 1, body: echoed, ...base(2) } + ]) + expect(renderJournalState(state).items).toMatchObject([ + { itemId: agentJournalSubmissionKey('agent-send'), body: { from } } + ]) + }) + it.each([5, 10])( 'reconciles %i rapid sends across an interleaved cancel when Codex reuses the root turn', (count) => { diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index e2d1b1ef3ca3..af92bb1fba47 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -22,7 +22,7 @@ import { agentJournalSubmissionKey, parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { JournalDerivedTurnScope } from './journal-derived-turn-scope' import { removeJournalItem, statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import { journalItemRevisionIsStale } from './journal-item-revision' @@ -202,11 +202,7 @@ export function journalEchoClaimant( if (!body || !isProviderUserMessageEcho(itemId, body)) { return null } - const fingerprint = structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: state.sessionId, - fields: { body } - }) + const fingerprint = agentSessionSendBodyFingerprint(state.sessionId, body) // Exact payload plus queue order preserves repeated identical sends one-for-one. // A submission an echo may not claim is one that says the message never reached // the provider, so an item resembling it is somebody else's. That is `rejected` diff --git a/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts b/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts index 6c8811eb4a5b..e63653e2f33a 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts @@ -2,6 +2,7 @@ // the row's content is checked (journal-row-schema.ts). import { isAdmissibleAgentSessionContextUsage } from '../../../shared/agent-session-context-usage-schema' +import { normalizeAgentMessageBodyFrom } from '../../../shared/agent-session-message-source' export function dropUnusableRowAnnotations(record: Record): void { dropUnusableProducerLinkage(record) @@ -15,6 +16,23 @@ export function dropUnusableRowAnnotations(record: Record): voi } } dropUnusableContextUsage(record) + normalizeMessageSenders(record) +} + +/** A message's sender read through its one reader: what it cannot read is dropped, and the + * message stays (an agent's with whatever senders read, else the person's). */ +function normalizeMessageSenders(record: Record): void { + const bodies = [ + record.kind === 'item' || record.kind === 'submission' ? record.body : undefined, + ...(record.kind === 'lifecycle-batch' && Array.isArray(record.mutations) + ? record.mutations.map((mutation) => (isPlainObject(mutation) ? mutation.body : undefined)) + : []) + ] + for (const body of bodies) { + if (isPlainObject(body) && body.kind === 'message') { + normalizeAgentMessageBodyFrom(body) + } + } } /** Linkage ids this build cannot trust, removed from a row it still keeps. diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts index d0306fa94679..f9588b71ba3a 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -51,8 +51,7 @@ async function handOff(journal: AgentSessionJournal, draftId: string, submission messageId: draftId, body: BODY, fingerprint: 'fp', - hostInstance: 'p', - source: { kind: 'user' } + hostInstance: 'p' }) await journal.appendSubmission( { clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 }, @@ -101,8 +100,7 @@ describe('the submission names the queued draft it hands off', () => { messageId: 'draft-1', body: BODY, fingerprint: 'fp', - hostInstance: 'p', - source: { kind: 'user' } + hostInstance: 'p' }) await expect( journal.appendSubmission( diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts index 60b613ea307c..5f9a95a53d20 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -65,8 +65,7 @@ async function queueAndConsume(journal: AgentSessionJournal, messageId: string): messageId, body, fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1', - source: { kind: 'user' } + hostInstance: 'proc-1' }) await journal.appendSubmission( { @@ -173,8 +172,7 @@ describe('draft bookkeeping inside a journal append', () => { messageId: 'draft-1', body: BODY, fingerprint: 'fp-draft-1', - hostInstance: 'proc-1', - source: { kind: 'user' } + hostInstance: 'proc-1' }) expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }]) const commit = failNextCommit() diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts index 88caf16949aa..e6905e848460 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts @@ -13,7 +13,7 @@ import type { } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { queuedMessageFingerprint } from '../agent-session-wire/structured-agent-session-queued-messages' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { JournalQueuedMessages } from './journal-queued-messages' import type { AgentSessionJournal } from './journal-store' import { createTrackedJournalOpener } from './journal-host-database-test-support' @@ -72,13 +72,12 @@ async function handOffAndReject( options: { handedOver: boolean } = { handedOver: true } ): Promise { const body = message(text) - const fingerprint = queuedMessageFingerprint(IDENTITY.sessionId, body) + const fingerprint = agentSessionSendBodyFingerprint(IDENTITY.sessionId, body) await journal.queuedMessages.insert({ messageId: 'draft-1', body, fingerprint, - hostInstance: 'p', - source: { kind: 'user' } + hostInstance: 'p' }) await journal.appendSubmission( { @@ -152,7 +151,7 @@ describe("a waiting draft whose 'never delivered' claim an echo disproves", () = const body = message('did it land?') await journal.appendSubmission({ clientMessageId: 'typed-again', - payloadFingerprint: queuedMessageFingerprint(IDENTITY.sessionId, body), + payloadFingerprint: agentSessionSendBodyFingerprint(IDENTITY.sessionId, body), body, fence: 0, handoverRecorded: true diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts index f111b49037f1..7d739a3f6224 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts @@ -4,7 +4,7 @@ // submission may not claim it), so it is read here, from the row itself. import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' -import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { isProviderUserMessageEcho, journalEchoClaimant, @@ -107,11 +107,7 @@ function echoProvesDelivered( ): boolean { return ( sequence > spent.since && - structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: state.sessionId, - fields: { body } - }) === spent.draft.fingerprint + agentSessionSendBodyFingerprint(state.sessionId, body) === spent.draft.fingerprint ) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 5989a10e9675..a2fa19a6a5c3 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -63,7 +63,6 @@ function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom body: message(messageId), fingerprint: `fp-${messageId}`, hostInstance: HOST, - source: { kind: 'user' }, ...(carriedFrom ? { carriedFrom } : {}) }) } @@ -496,8 +495,7 @@ describe("a restart's pause", () => { messageId: 'draft-restart', body: message('written before the restart'), fingerprint: 'fp-draft-restart', - hostInstance: 'proc-0', - source: { kind: 'user' } + hostInstance: 'proc-0' }) expect(reason(journal)).toBe('restarted') await queueDraft(journal, 'draft-legacy') diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts index d19c2e09a2db..3f9d0122fda2 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-schema.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -12,8 +12,7 @@ const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ ['consumed_as', 'TEXT'], ['carried_from', 'TEXT'], ['queued_epoch', 'TEXT'], - ['queued_sequence', 'INTEGER'], - ['source_json', 'TEXT'] + ['queued_sequence', 'INTEGER'] ] /** @@ -44,7 +43,6 @@ CREATE TABLE IF NOT EXISTS queued_messages ( carried_from TEXT, queued_epoch TEXT, queued_sequence INTEGER, - source_json TEXT, PRIMARY KEY (session_id, message_id) ); `) diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index a9c0ef7c70cc..94932406bb3e 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -22,7 +22,7 @@ import { QueuedMessageNotConsumableError } from './journal-queued-messages' import type { AgentSessionJournal } from './journal-store' -import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { closeTestJournalHostDatabases, createTrackedJournalOpener @@ -82,8 +82,7 @@ async function queueDraft(journal: AgentSessionJournal, messageId: string, text messageId, body: message(text), fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1', - source: { kind: 'user' } + hostInstance: 'proc-1' }) } @@ -176,8 +175,8 @@ describe('draft rows', () => { expect(journal.queuedMessages.list()).toHaveLength(1) }) - it("keeps who queued a card across reopen; a card with no readable sender is the person's", async () => { - const agent: AgentSessionMessageSource = { + it("keeps an agent card's sender on its body across reopen, read through the one reader", async () => { + const from: AgentMessageSource = { kind: 'agent', senders: [ { @@ -185,7 +184,8 @@ describe('draft rows', () => { address: 'structworker_1', terminalHandle: 'structworker_1', orcaSessionId: null - } + }, + name: 'Reviewer' } ], orchestration: { @@ -196,31 +196,43 @@ describe('draft rows', () => { } } const first = await open() - await first.queuedMessages.insert({ - messageId: 'agent-card', - body: message('You have 1 orchestration message. Run `orca orchestration check --run r1`.'), - fingerprint: 'fp-agent-card', - hostInstance: 'proc-1', - source: agent + const insert = (messageId: string, body: AgentJournalMessageItem) => + first.queuedMessages.insert({ + messageId, + body, + fingerprint: `fp-${messageId}`, + hostInstance: 'proc-1' + }) + await insert('agent-card', { ...message('You have 1 orchestration message.'), from }) + await insert('newer-kind', { + ...message('a task'), + from: { ...from, orchestration: null } }) - await queueDraft(first, 'before-the-column') - await queueDraft(first, 'unreadable') + await insert('malformed', message('typed')) await first.close() closeTestJournalHostDatabases() const db = new Database(journalDatabasePath(root)) - db.prepare('UPDATE queued_messages SET source_json = NULL WHERE message_id = ?').run( - 'before-the-column' + // A newer build's message kind, and a value no build writes. + const setFrom = db.prepare( + "UPDATE queued_messages SET body_json = json_set(body_json, '$.from', json(?)) WHERE message_id = ?" ) - db.prepare('UPDATE queued_messages SET source_json = \'{"v":9}\' WHERE message_id = ?').run( - 'unreadable' + setFrom.run( + JSON.stringify({ ...from, orchestration: { message: 'task', taskId: 't1' } }), + 'newer-kind' ) + setFrom.run(JSON.stringify('nobody'), 'malformed') + // A table from a build that also kept the sender in a column of its own. + db.exec('ALTER TABLE queued_messages ADD COLUMN source_json TEXT') db.close() const reopened = await open() - expect(reopened.queuedMessages.list().map((row) => [row.messageId, row.source])).toEqual([ - ['agent-card', agent], - ['before-the-column', { kind: 'user' }], - ['unreadable', { kind: 'user' }] + expect(reopened.queuedMessages.list().map((row) => [row.messageId, row.body.from])).toEqual([ + ['agent-card', from], + ['newer-kind', { ...from, orchestration: null }], + ['malformed', undefined] ]) + // That older table still takes new cards. + await queueDraft(reopened, 'after') + expect(reopened.queuedMessages.list()).toHaveLength(4) }) it('drafts survive epoch replacement, which deletes only journal rows', async () => { diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index 1a82925f1f9a..1558295c9cb5 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -13,11 +13,7 @@ import type { AgentJournalCursor, AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { - readAgentSessionMessageSource, - serializeAgentSessionMessageSource, - type AgentSessionMessageSource -} from '../../../shared/agent-session-message-source' +import { normalizeAgentMessageBodyFrom } from '../../../shared/agent-session-message-source' import { rejectedDraftSettlement } from './journal-dispatch-settlement' import { readStoredRejectionFact } from './journal-dispatch-reducer' @@ -64,12 +60,10 @@ export type QueuedMessageRow = { /** Where the journal stood when it was queued: a Stop's pause holds only cards queued before * it. Null on rows from builds before it was recorded, which read as queued before any Stop. */ queuedAt: AgentJournalCursor | null - /** Who it is from: the person, or another agent through Orca. */ - source: AgentSessionMessageSource } const COLUMNS = - 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence, source_json' + 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence' export function insertQueuedMessage( db: Database.Database, @@ -81,7 +75,6 @@ export function insertQueuedMessage( hostInstance: string carriedFrom?: string queuedAt: AgentJournalCursor - source: AgentSessionMessageSource now: number } ): QueuedMessageRow { @@ -92,7 +85,7 @@ export function insertQueuedMessage( const position = Number(highest?.p ?? 0) + 1 db.prepare( `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)` ).run( input.sessionId, input.messageId, @@ -103,8 +96,7 @@ export function insertQueuedMessage( input.hostInstance, input.carriedFrom ?? null, input.queuedAt.epoch, - input.queuedAt.sequence, - serializeAgentSessionMessageSource(input.source) + input.queuedAt.sequence ) return { sessionId: input.sessionId, @@ -122,8 +114,7 @@ export function insertQueuedMessage( settledByOp: null, consumedAs: null, carriedFrom: input.carriedFrom ?? null, - queuedAt: input.queuedAt, - source: input.source + queuedAt: input.queuedAt } } @@ -305,12 +296,12 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { carried_from: string | null queued_epoch: string | null queued_sequence: number | null - source_json: string | null } let body: AgentJournalMessageItem try { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: body_json is written only by insertQueuedMessage from a schema-validated AgentJournalMessageItem. body = JSON.parse(record.body_json) as AgentJournalMessageItem + normalizeAgentMessageBodyFrom(body) } catch { // Our own writer stringified it; an unreadable body is corruption, and a // row we cannot re-materialize must not masquerade as an empty message. @@ -344,21 +335,10 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { queuedAt: record.queued_epoch !== null && typeof record.queued_sequence === 'number' ? { epoch: record.queued_epoch, sequence: record.queued_sequence } - : null, - source: storedSource(record.source_json) + : null } } -function storedSource(json: string | null): AgentSessionMessageSource { - let stored: unknown = null - try { - stored = json === null ? null : JSON.parse(json) - } catch { - // An unreadable value is read as no value; the source reader decides what that means. - } - return readAgentSessionMessageSource(stored) -} - function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { if (json === null) { return null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index ab632de9dda8..3b9e69dc0a40 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -37,7 +37,6 @@ import { setOptionPlan } from './structured-agent-session-mutation-plans' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' -import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import { mutateWithChatStop } from './structured-agent-session-chat-stop' export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' @@ -61,9 +60,6 @@ export function sendStructuredAgentSessionTurn( * Orchestration mail, a restart continuation and `agent.launch`'s host-sent * prompt never set it. */ userSend?: true - /** Host-local, never on the wire: who a host-side `queue-if-active` send queues for, recorded - * on its card. A client's send is always its person's (`userSend`). */ - source?: AgentMessageSource beforeRun?: () => void }, arrival?: Parameters[2] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 6a2a6268ae94..9217e2794688 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -19,7 +19,10 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' -import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { + agentSessionMessagePayload, + agentSessionSendBodyFingerprint +} from '../../../shared/structured-agent-session-send-mutation' import { STRUCTURED_AGENT_SESSION_COMPACT_COMMAND, structuredAgentSessionCompactBody @@ -35,16 +38,6 @@ import { import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' -/** The body-only hash: what the reducer recomputes to alias a provider echo - * onto its submission, so the stored value must never include control fields. */ -function sendBodyFingerprint(sessionId: string, body: AgentJournalMessageItem): string { - return structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body } - }) -} - export type MutationPlan = { method: string fields: Record @@ -89,7 +82,10 @@ export function sendPlan(params: { settlesWithWrite: true, // `delivery` joins the OPERATION fingerprint only; the submission row keeps // the body-only fingerprint the reducer's echo-aliasing recomputes. - fields: { body: params.body, ...(params.delivery ? { delivery: params.delivery } : {}) }, + fields: { + body: agentSessionMessagePayload(params.body), + ...(params.delivery ? { delivery: params.delivery } : {}) + }, recoverUnknownFromDurableState: true, // `retryUnknown` is a compatibility-only client signal. A recorded send // always replays and never reaches the provider twice. @@ -99,7 +95,8 @@ export function sendPlan(params: { return performSend(ctx, { origin: params.userSend ? 'client' : 'host', clientMessageId, - payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body), + // Body-only, so the reducer's echo aliasing never sees control fields or the sender. + payloadFingerprint: agentSessionSendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body }) }, 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 668209d15138..bcb7b990dabc 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 @@ -11,6 +11,7 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-ses import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { agentSessionMessagePayload } from '../../../shared/structured-agent-session-send-mutation' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -32,7 +33,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } -type RigSendOptions = { internal?: true; source?: AgentMessageSource } +type RigSendOptions = { internal?: true; from?: AgentMessageSource } export function eventually(assertion: () => void | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) @@ -140,16 +141,18 @@ export async function createQueuedMessageTestRig( } /** A client's send, as the `agentSession.send` RPC hands it to the host; - * `internal` is a host-side sender (orchestration mail, a restart continuation), and `source` - * who it is from. */ + * `internal` is a host-side sender (orchestration mail, a restart continuation), and `from` + * the agent it is from. */ function send(text: string, delivery?: 'queue-if-active', options?: RigSendOptions) { - const body = hostTestMessage(text) + const body = { ...hostTestMessage(text), ...(options?.from ? { from: options.from } : {}) } const clientOperationId = hostTestOperationId() - const fields = { body, ...(delivery ? { delivery } : {}) } + // Fingerprinted as the host digests a send: the message without its sender. + const fields = { body: agentSessionMessagePayload(body), ...(delivery ? { delivery } : {}) } const result = host.send(QUEUED_RIG_CALLER, { envelope: envelope(fields, 'agentSession.send', clientOperationId), ...fields, - ...(options?.internal ? { source: options.source } : { userSend: true as const }) + body, + ...(options?.internal ? {} : { userSend: true as const }) }) return { id: clientOperationId, result } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index eefd106b3eaa..65613b2e621a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -13,6 +13,7 @@ import { } from '../../../shared/agent-session-wire' import { ConversationCommandParams } from '../../../shared/rpc-contract/structured-agent-session-params' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' import { @@ -637,16 +638,15 @@ describe('/clear', () => { }) }) - it('carries who each card is from', async () => { - const notice = { - message: 'mail-notice', - mailbox: 'run:r1', - dispatchId: null, - messages: [] - } as const - const source: AgentMessageSource = { kind: 'agent', senders: [], orchestration: notice } + it("carries who each card is from on its body, fingerprinted for the replacement's echo", async () => { + const party = { address: 'term_peer', terminalHandle: 'term_peer', orcaSessionId: null } + const from: AgentMessageSource = { + kind: 'agent', + senders: [{ party, name: 'Claude' }], + orchestration: { message: 'mail-notice', mailbox: 'run:r1', dispatchId: null, messages: [] } + } const working = await workingSend() - await send('pointer', 'queue-if-active', { internal: true, source }).result + await send('pointer', 'queue-if-active', { internal: true, from }).result await send('typed', 'queue-if-active').result await stop() await settleAccepted(working, 'a') @@ -655,11 +655,13 @@ describe('/clear', () => { if (!replacementId) { throw new Error('expected a replacement session') } - const journal = host.collaboratorsForTests().sessions.get(replacementId)?.journal - expect(journal?.queuedMessages.list().map((row) => row.source)).toEqual([ - source, - { kind: 'user' } - ]) + const rows = + host.collaboratorsForTests().sessions.get(replacementId)?.journal.queuedMessages.list() ?? [] + expect(rows.map((row) => row.body.from)).toEqual([from, undefined]) + // The sender is outside the fingerprint, or the provider's echo of the text would not match. + expect(rows[0]?.fingerprint).toBe( + agentSessionSendBodyFingerprint(replacementId, hostTestMessage('pointer')) + ) }) it("the replacement's 'cleared' pause lifts through Resume exactly like a Stop's", async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index d4c7c470f12a..776f960f588b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -8,19 +8,13 @@ import { randomUUID } from 'node:crypto' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { - USER_MESSAGE_SOURCE, - type AgentMessageSource -} from '../../../shared/agent-session-message-source' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionSendResult, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { - createStructuredAgentSessionOperationId, - structuredAgentSessionPayloadFingerprint -} from '../../../shared/structured-agent-session-mutation' +import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' import { structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' import { isUnsettledQueuedMessage } from '../agent-session-journal/queued-message-table' @@ -151,16 +145,6 @@ export function shouldQueueStructuredAgentSessionSend(input: { return oldestActionableQueuedMessage(input.journal) !== null } -/** A draft's payload fingerprint in the session that will send it: the reducer - * aliases the provider's echo to the submission by recomputing exactly this. */ -export function queuedMessageFingerprint(sessionId: string, body: AgentJournalMessageItem): string { - return structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body } - }) -} - /** The accept-side budget refusal, or null when the draft fits. */ export function queuedMessageBudgetRefusal( journal: AgentSessionJournal, @@ -195,10 +179,6 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' - /** A person's send at a chat surface; it outranks any `source`. */ - userSend?: true - /** Who a host-side send is from. */ - source?: AgentMessageSource } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -238,9 +218,9 @@ export async function maybeQueueStructuredAgentSessionSend( { messageId: clientMessageId, body: params.body, - fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance(), - source: params.userSend ? USER_MESSAGE_SOURCE : (params.source ?? USER_MESSAGE_SOURCE) + // In the session that will send it: the reducer aliases the provider's echo by exactly this. + fingerprint: agentSessionSendBodyFingerprint(ctx.sessionId, params.body), + hostInstance: structuredAgentSessionHostInstance() }, ctx.operationReceipt ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 8554bb081546..14d6cc90d862 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -22,10 +22,8 @@ import { } from '../agent-session-journal/journal-open-failure' import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' import type { MutationPlan } from './structured-agent-session-mutation-plans' -import { - queuedMessageFingerprint, - structuredQueueHold -} from './structured-agent-session-queued-messages' +import { structuredQueueHold } from './structured-agent-session-queued-messages' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { resumeStructuredQueue, structuredAgentSessionHostInstance @@ -115,10 +113,9 @@ export async function carryQueuedMessagesToClearReplacement( await replacement.queuedMessages.insert({ messageId: row.messageId, body: row.body, - fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), + fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId, - source: row.source + carriedFrom: ctx.sessionId }) } await withdrawQueuedMessagesForOperation(ctx.journal, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index 9abdc713f0e1..1b394ebaf558 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -4,7 +4,6 @@ import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { maybeQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -16,8 +15,6 @@ export async function runQueueableStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' - userSend?: true - source?: AgentMessageSource }, immediate: () => Promise> ): Promise> { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index a1bb644d84e0..20cd3b298d56 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -519,8 +519,7 @@ describe('startup restore of chats still in their per-chat files', () => { messageId: 'draft-1', body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, fingerprint: 'fp-draft-1', - hostInstance: 'proc-1', - source: { kind: 'user' } + hostInstance: 'proc-1' }) expect(journal.importPending).toBe(false) diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 8b406b2df6a6..c79619e7cb23 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -2,6 +2,11 @@ import { OrchestrationStructuredMailboxPointerDelivery } from './orchestration/structured-mailbox-pointer-delivery' import { createStructuredMailboxPointerHost } from './orchestration/structured-mailbox-pointer-host' import { localOrchestrationCliCommand } from './orchestration/cli-command' +import { readAgentSessionRecordStore } from './orchestration/structured-session-lineage' +import { + orchestrationSenderName, + type TerminalSenderNaming +} from './orchestration/orchestration-sender-name' import { isStructuredWorkerHandle } from './structured-worker-identity' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { OrcaRuntimeWithRuntimeId } from './orca-runtime-runtime-id' @@ -255,9 +260,30 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId resolveStructuredTarget: (mailboxHandle) => this.resolveStructuredMailboxTarget(mailboxHandle), getCliCommand: localOrchestrationCliCommand, + senderName: (party) => + orchestrationSenderName(party, { + db: this._orchestrationDb, + records: readAgentSessionRecordStore(), + terminal: (handle) => this.terminalSenderNaming(handle) + }), host: createStructuredMailboxPointerHost() }) + protected terminalSenderNaming(handle: string): TerminalSenderNaming | null { + const record = this.handles.get(handle) + if (!record) { + return null + } + const pty = record.ptyId ? this.ptysById.get(record.ptyId) : undefined + const tab = this.getWorkspaceSessionForWorktree(record.worktreeId)?.tabsByWorktree?.[ + record.worktreeId + ]?.find((candidate) => candidate.id === record.tabId) + return { + customTitle: tab?.customTitle ?? null, + agent: pty?.launchAgent ?? pty?.foregroundAgent ?? null + } + } + protected readonly orchestrationMailboxNotifications = new OrchestrationMailboxNotificationCoordinator({ mailboxOwner: this.orchestrationMailboxOwner, diff --git a/src/main/runtime/orchestration/agent-facing-parity.test.ts b/src/main/runtime/orchestration/agent-facing-parity.test.ts index e2926d5bf9a3..6aee5f6b955d 100644 --- a/src/main/runtime/orchestration/agent-facing-parity.test.ts +++ b/src/main/runtime/orchestration/agent-facing-parity.test.ts @@ -120,6 +120,7 @@ async function renderChatPointer(mailbox: string): Promise { resolveStructuredTarget: () => ({ sessionId: CHAT_SESSION, dispatchId: null }), // The runtime's wiring of the structured lane. getCliCommand: localOrchestrationCliCommand, + senderName: () => null, host: { readSessionFacts: async () => ({ submissions: [] }), currentFence: () => 1, diff --git a/src/main/runtime/orchestration/orchestration-party-location.ts b/src/main/runtime/orchestration/orchestration-party-location.ts new file mode 100644 index 000000000000..7acc7dd2b197 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-party-location.ts @@ -0,0 +1,62 @@ +// Where a party named by its mailbox address is now, so a chat can open a message's sender. + +import type { OrchestrationPartyLocation } from '../../../shared/orchestration-caller-status' +import type { OrcaSessionId } from '../../../shared/orca-session-address' +import type { OrchestrationDb } from './db' +import { resolveOrchestrationParty } from './orchestration-party' +import { lineageLiveSession, type AgentSessionRecordReader } from './structured-session-lineage' + +type PartyLocationDeps = { + db: OrchestrationDb | null + records: AgentSessionRecordReader | null + terminalHandleForPaneKey: (paneKey: string) => string | null +} + +export function locateOrchestrationParty( + address: string, + deps: PartyLocationDeps +): OrchestrationPartyLocation | null { + if (address.startsWith('dispatch:')) { + return locateDispatchAssignee(address.slice('dispatch:'.length), deps) + } + let party: ReturnType + try { + party = resolveOrchestrationParty(address, deps.db) + } catch { + // A worker this host lost the identity of. + return null + } + if (party.orcaSessionId) { + return locateSession(party.orcaSessionId, deps.records) + } + return party.terminalHandle ? { kind: 'terminal', handle: party.terminalHandle } : null +} + +/** As mail to the dispatch is routed (mailbox-delivery-target.ts): its assignee's pane, else + * its recorded handle, else a remote attachment's. */ +function locateDispatchAssignee( + dispatchId: string, + deps: PartyLocationDeps +): OrchestrationPartyLocation | null { + const dispatch = deps.db?.getDispatchContextById(dispatchId) + if (dispatch?.assignee_orca_session_id) { + return locateSession(dispatch.assignee_orca_session_id, deps.records) + } + const remote = dispatch ? undefined : deps.db?.getRemoteDispatchAttachment(dispatchId) + const paneKey = dispatch?.assignee_pane_key ?? remote?.pane_key + const handle = + (paneKey ? deps.terminalHandleForPaneKey(paneKey) : null) ?? + dispatch?.assignee_handle ?? + remote?.terminal_handle + return handle && !handle.startsWith('dispatch:') ? locateOrchestrationParty(handle, deps) : null +} + +function locateSession( + orcaSessionId: OrcaSessionId, + records: AgentSessionRecordReader | null +): OrchestrationPartyLocation | null { + const record = records ? lineageLiveSession(records, orcaSessionId) : null + return record + ? { kind: 'chat', sessionId: record.sessionId, worktreeId: record.location.workspaceId } + : null +} diff --git a/src/main/runtime/orchestration/orchestration-party.ts b/src/main/runtime/orchestration/orchestration-party.ts index 579b36d9216b..bc1cdf471346 100644 --- a/src/main/runtime/orchestration/orchestration-party.ts +++ b/src/main/runtime/orchestration/orchestration-party.ts @@ -26,14 +26,16 @@ export type OrchestrationSessionParty = OrchestrationParty & const NO_EFFECTS = { effectsApplied: false } as const /** Every param naming a party other than the caller; a new one is added here with its own test. */ -export const ORCHESTRATION_TARGET_PARAM: Readonly> = - { - 'orchestration.send': 'to', - 'orchestration.ask': 'to', - 'orchestration.dispatch': 'to', - 'orchestration.inbox': 'terminal', - 'orchestration.sessionAddress': 'sessionId' - } +export const ORCHESTRATION_TARGET_PARAM: Readonly< + Record +> = { + 'orchestration.send': 'to', + 'orchestration.ask': 'to', + 'orchestration.dispatch': 'to', + 'orchestration.inbox': 'terminal', + 'orchestration.sessionAddress': 'sessionId', + 'orchestration.partyLocation': 'address' +} /** The party an Orca session id names. Throws when it is a worker this host lost the identity of. */ export function resolveOrcaSessionParty( diff --git a/src/main/runtime/orchestration/orchestration-sender-location.test.ts b/src/main/runtime/orchestration/orchestration-sender-location.test.ts new file mode 100644 index 000000000000..312bd3916ee3 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-sender-location.test.ts @@ -0,0 +1,133 @@ +// What a chat names a message's sender, and where it opens it: the host's two reads of a party. + +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import type { OrchestrationDb } from './db' +import type { DispatchContextRow, TaskRow } from './types' +import { orchestrationSenderName } from './orchestration-sender-name' +import { locateOrchestrationParty } from './orchestration-party-location' +import type { AgentSessionRecordReader } from './structured-session-lineage' + +const ROOT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +const LIVE = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' + +function record(sessionId: string, overrides: Partial = {}) { + const base = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) + return { ...base, location: { ...base.location, workspaceId: 'wt-chat' }, ...overrides } +} + +/** A chat `/clear` continued in LIVE: its root id still names it. */ +function clearedChat(liveName?: string): AgentSessionRecordReader { + const records = new Map([ + [ + ROOT, + record(ROOT, { + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: LIVE, + operationId: 'op', + callerKey: 'k', + phase: 'committed' + } + }) + ], + [LIVE, record(LIVE, liveName ? { conversationName: liveName } : {})] + ]) + return { getRecord: (id) => records.get(id) ?? null, listRecords: () => [...records.values()] } +} + +/** Only the dispatch lookups the reads make. */ +function dispatchDb(dispatch?: Partial, task?: Partial) { + const db = { + getDispatchContextById: () => dispatch, + getTask: () => task, + getRemoteDispatchAttachment: () => undefined + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reads under test call only these three lookups, and read only the fields set here. + return db as unknown as OrchestrationDb +} + +const chatParty = { address: `orca_session_id:${ROOT}`, terminalHandle: null, orcaSessionId: ROOT } +const terminalParty = { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null } +const dispatchParty = { address: 'dispatch:d1', terminalHandle: 'dispatch:d1', orcaSessionId: null } + +describe("a sender's name, from names Orca controls", () => { + const name = ( + party: Parameters[0], + deps: Partial[1]> = {} + ) => orchestrationSenderName(party, { db: null, records: null, terminal: () => null, ...deps }) + + it("names a chat by its live session's conversation name, else its agent's chat label", () => { + expect(name(chatParty, { records: clearedChat('Fix the build') })).toBe('Fix the build') + expect(name(chatParty, { records: clearedChat() })).toBe('Claude Chat') + expect(name(chatParty)).toBeNull() + }) + + it('names a terminal by the title the person gave its tab, else its agent', () => { + expect( + name(terminalParty, { terminal: () => ({ customTitle: ' Lint ', agent: 'codex' }) }) + ).toBe('Lint') + expect(name(terminalParty, { terminal: () => ({ customTitle: null, agent: 'codex' }) })).toBe( + 'Codex' + ) + expect(name(terminalParty, { terminal: () => ({ customTitle: null, agent: null }) })).toBeNull() + }) + + it('names a federated sender by the task its dispatch was given', () => { + const db = dispatchDb({ task_id: 't1' }, { display_name: 'Port the parser', task_title: 'x' }) + expect(name(dispatchParty, { db })).toBe('Port the parser') + expect(name(dispatchParty, { db: dispatchDb(undefined) })).toBeNull() + }) +}) + +describe('where a sender opens', () => { + const locate = (address: string, deps: Partial[1]>) => + locateOrchestrationParty(address, { + db: null, + records: null, + terminalHandleForPaneKey: () => null, + ...deps + }) + + it("opens a chat at its `/clear` lineage's live session, in that session's worktree", () => { + expect(locate(chatParty.address, { records: clearedChat() })).toEqual({ + kind: 'chat', + sessionId: LIVE, + worktreeId: 'wt-chat' + }) + expect(locate(chatParty.address, {})).toBeNull() + }) + + it('opens a terminal at its handle', () => { + expect(locate('term_a', {})).toEqual({ kind: 'terminal', handle: 'term_a' }) + }) + + it("opens a dispatch at its assignee: a chat by its session, a terminal by its pane's handle now", () => { + const chatAssignee = dispatchDb({ assignee_orca_session_id: ROOT }) + expect(locate('dispatch:d1', { db: chatAssignee, records: clearedChat() })).toMatchObject({ + kind: 'chat', + sessionId: LIVE + }) + const terminalAssignee = dispatchDb({ + assignee_pane_key: 'tab:leaf', + assignee_handle: 'term_old' + }) + expect( + locate('dispatch:d1', { + db: terminalAssignee, + terminalHandleForPaneKey: (paneKey) => (paneKey === 'tab:leaf' ? 'term_now' : null) + }) + ).toEqual({ kind: 'terminal', handle: 'term_now' }) + expect(locate('dispatch:d1', { db: terminalAssignee })).toEqual({ + kind: 'terminal', + handle: 'term_old' + }) + expect(locate('dispatch:d1', { db: dispatchDb(undefined) })).toBeNull() + }) +}) diff --git a/src/main/runtime/orchestration/orchestration-sender-name.ts b/src/main/runtime/orchestration/orchestration-sender-name.ts new file mode 100644 index 000000000000..eb51251dc66f --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-sender-name.ts @@ -0,0 +1,48 @@ +// What a chat shows a message's sender as: only names Orca controls, never a title the agent +// set on its own terminal (it could call itself "You"). + +import type { AgentMessageSender } from '../../../shared/agent-session-message-source' +import { defaultAgentChatLabel } from '../../../shared/agent-session-chat-label' +import type { AgentType } from '../../../shared/agent-status-types' +import { formatAgentTypeLabel } from '../../../shared/agent-type-label' +import type { OrchestrationDb } from './db' +import { lineageLiveSession, type AgentSessionRecordReader } from './structured-session-lineage' + +export type TerminalSenderNaming = { + /** The title the person gave the tab, if any. */ + customTitle: string | null + agent: AgentType | null +} + +export function orchestrationSenderName( + party: AgentMessageSender['party'], + deps: { + db: OrchestrationDb | null + records: AgentSessionRecordReader | null + terminal: (handle: string) => TerminalSenderNaming | null + } +): string | null { + // A federated sender is its dispatch here: named by the task it was given. + const dispatchId = party.address.startsWith('dispatch:') + ? party.address.slice('dispatch:'.length) + : null + if (dispatchId) { + const dispatch = deps.db?.getDispatchContextById(dispatchId) + const task = dispatch ? deps.db?.getTask(dispatch.task_id) : undefined + return task?.display_name || task?.task_title || null + } + if (party.orcaSessionId) { + const record = deps.records ? lineageLiveSession(deps.records, party.orcaSessionId) : null + if (record) { + return record.conversationName ?? defaultAgentChatLabel(record.provider) + } + } + if (party.terminalHandle) { + const terminal = deps.terminal(party.terminalHandle) + if (terminal) { + const agentLabel = terminal.agent ? formatAgentTypeLabel(terminal.agent) : null + return terminal.customTitle?.trim() || agentLabel + } + } + return null +} diff --git a/src/main/runtime/orchestration/send-agent-turn-host.test.ts b/src/main/runtime/orchestration/send-agent-turn-host.test.ts index 0f92d95cee8d..0980d3b26251 100644 --- a/src/main/runtime/orchestration/send-agent-turn-host.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn-host.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { createQueuedMessageTestRig, eventually, @@ -29,7 +30,8 @@ const MAIL_SOURCE: AgentMessageSource = { address: 'term_peer', terminalHandle: 'term_peer', orcaSessionId: null - } + }, + name: 'Claude' } ], orchestration: { @@ -57,14 +59,20 @@ function sendTurn( sessionId: SESSION, callerKey: 'trusted-local:orchestration:d1', turn: { - body: hostTestMessage('mail'), + body: { ...hostTestMessage('mail'), from: MAIL_SOURCE }, operationId, expectedRuntimeFence: 1, - ...(delivery === 'queue' ? { delivery, source: MAIL_SOURCE } : { delivery }) + delivery } }) } +/** The transcript item a send became. */ +async function sentMessage(clientMessageId: string) { + const snapshot = await rig.host.journalSnapshot(SESSION) + return snapshot.items.find((item) => item.itemId === agentJournalSubmissionKey(clientMessageId)) +} + describe('sendAgentTurn through the real host', () => { it('has a `queue` send held as a draft while the agent works', async () => { await rig.workingSend() @@ -72,16 +80,26 @@ describe('sendAgentTurn through the real host', () => { kind: 'queued', queued: { position: 1, state: 'waiting' } }) - // Stored with the card, read back whole: who it is from survives the round trip. + // On the card's body, read back whole and published with it. expect( rig.host .collaboratorsForTests() .sessions.get(SESSION) ?.journal.queuedMessages.list() - .map(({ state, source }) => ({ state, source })) - ).toEqual([{ state: 'waiting', source: MAIL_SOURCE }]) - // Shown in the chat's queue like the person's own card. - expect(await rig.drafts()).toMatchObject([{ state: 'waiting' }]) + .map(({ state, body }) => ({ state, from: body.from })) + ).toEqual([{ state: 'waiting', from: MAIL_SOURCE }]) + const page = await rig.host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.map(({ body }) => body.from)).toEqual([MAIL_SOURCE]) + }) + + it('keeps the sender on the turn the queue sends', async () => { + const working = await rig.workingSend() + const operationId = hostTestOperationId() + await sendTurn('queue', operationId) + await rig.settleAccepted(working, 'work') + await eventually(async () => expect(await rig.handoff(operationId)).toBeDefined()) + const item = await sentMessage(await rig.handoffId(operationId)) + expect(item?.body).toMatchObject({ from: MAIL_SOURCE }) }) it('replays a retried `queue` send instead of refusing it', async () => { @@ -136,11 +154,12 @@ describe('sendAgentTurn through the real host', () => { } it.each(['now', 'queue'] as const)('sends a `%s` turn to an idle agent', async (delivery) => { - await expect(sendTurnAccepted(delivery)).resolves.toMatchObject({ - kind: 'sent', - submission: { dispatchState: 'accepted' } - }) + const outcome = await sendTurnAccepted(delivery) + expect(outcome).toMatchObject({ kind: 'sent', submission: { dispatchState: 'accepted' } }) expect(await rig.drafts()).toEqual([]) + // An idle chat never queues, and the sent message still names its sender. + const sent = outcome.kind === 'sent' ? outcome.clientMessageId : '' + expect((await sentMessage(sent))?.body).toMatchObject({ from: MAIL_SOURCE }) }) it('has a `now` send join the running turn, never the queue', async () => { diff --git a/src/main/runtime/orchestration/send-agent-turn.test.ts b/src/main/runtime/orchestration/send-agent-turn.test.ts index 599ef009ae55..bd90030a8bc4 100644 --- a/src/main/runtime/orchestration/send-agent-turn.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn.test.ts @@ -55,7 +55,12 @@ function structuredHost(answer: HostSendAnswer, settled?: AgentJournalSubmission const MAIL_SOURCE: AgentMessageSource = { kind: 'agent', - senders: [], + senders: [ + { + party: { address: 'term_peer', terminalHandle: 'term_peer', orcaSessionId: null }, + name: 'Claude' + } + ], orchestration: { message: 'mail-notice', mailbox: 'dispatch:d1', @@ -71,6 +76,12 @@ const turn: StructuredSessionTurn = { expectedRuntimeFence: 7 } +const mailTurn: StructuredSessionTurn = { + ...turn, + body: { ...turn.body, from: MAIL_SOURCE }, + delivery: 'queue' +} + const structured = (host: StructuredAgentTurnHost, sent: StructuredSessionTurn = turn) => ({ kind: 'structured-session', @@ -146,16 +157,14 @@ describe('sendAgentTurn to a structured session', () => { }) }) - it('asks the host queue to hold a `queue` send, fingerprinted as the host digests it', async () => { + it('asks the host queue to hold a `queue` send, fingerprinted without its sender', async () => { const fake = structuredHost( accepted({ clientMessageId: 'op-1', queued: { messageId: 'op-1', position: 0, state: 'waiting' } }) ) - await expect( - sendAgentTurn(structured(fake.host, { ...turn, delivery: 'queue', source: MAIL_SOURCE })) - ).resolves.toEqual({ + await expect(sendAgentTurn(structured(fake.host, mailTurn))).resolves.toEqual({ kind: 'queued', clientMessageId: 'op-1', queued: { messageId: 'op-1', position: 0, state: 'waiting' } @@ -167,12 +176,11 @@ describe('sendAgentTurn to a structured session', () => { sessionId: 's1', clientOperationId: 'op-1', expectedRuntimeFence: 7, + // The sender rides on the body, outside the fingerprint: a retry still replays. payloadFingerprint: hostFingerprint({ body: turn.body, delivery: 'queue-if-active' }) }, - body: turn.body, - delivery: 'queue-if-active', - // Host-local: who the card is from rides beside the envelope, outside its fingerprint. - source: MAIL_SOURCE + body: mailTurn.body, + delivery: 'queue-if-active' } ) expect(fake.waitForSendSettlement).not.toHaveBeenCalled() @@ -185,9 +193,10 @@ describe('sendAgentTurn to a structured session', () => { queued: { messageId: 'op-1', position: 0, state: 'returned' } }) ) - await expect( - sendAgentTurn(structured(fake.host, { ...turn, delivery: 'queue', source: MAIL_SOURCE })) - ).resolves.toMatchObject({ kind: 'queued', queued: { state: 'returned' } }) + await expect(sendAgentTurn(structured(fake.host, mailTurn))).resolves.toMatchObject({ + kind: 'queued', + queued: { state: 'returned' } + }) }) }) diff --git a/src/main/runtime/orchestration/send-agent-turn.ts b/src/main/runtime/orchestration/send-agent-turn.ts index 340bcc82e87a..9805a346b7ee 100644 --- a/src/main/runtime/orchestration/send-agent-turn.ts +++ b/src/main/runtime/orchestration/send-agent-turn.ts @@ -17,7 +17,6 @@ import { type AgentSessionQueuedSendReceipt } from '../../../shared/agent-session-wire' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals' -import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../shared/orchestration-timing-budgets' import { structuredAgentSessionMessageSendMutation } from '../../../shared/structured-agent-session-send-mutation' import type { StructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-host' @@ -36,15 +35,13 @@ export type StructuredAgentTurnHost = Pick< > export type StructuredSessionTurn = { + /** Carries its sender as `from`, which the chat shows and no fingerprint covers. */ body: AgentJournalMessageItem /** Reused on a retry, so the host replays its recorded answer instead of sending twice. */ operationId: string expectedRuntimeFence: number -} & ( - | { delivery: 'now' } - /** A queued card records who it is from. */ - | { delivery: 'queue'; source: AgentMessageSource } -) + delivery: AgentTurnDelivery +} export type StructuredSessionTurnSend = { kind: 'structured-session' @@ -134,11 +131,7 @@ async function sendStructuredSessionTurn( body: turn.body, delivery: turn.delivery === 'queue' ? 'queue-if-active' : undefined }) - const result = await send.host.send( - { callerKey: send.callerKey }, - // The source is host-local and outside the fingerprint: a retry under the same id replays. - turn.delivery === 'queue' ? { ...message, source: turn.source } : message - ) + const result = await send.host.send({ callerKey: send.callerKey }, message) if (!result.ok) { return { kind: 'refused', refusal: result.refusal } } diff --git a/src/main/runtime/orchestration/structured-mail-source.test.ts b/src/main/runtime/orchestration/structured-mail-source.test.ts index f6f394d722e2..fb55368dfae7 100644 --- a/src/main/runtime/orchestration/structured-mail-source.test.ts +++ b/src/main/runtime/orchestration/structured-mail-source.test.ts @@ -14,18 +14,23 @@ describe('who delivered mail is from', () => { { id: 'm1', from_handle: 'term_a', run_id: 'r1' }, { id: 'm2', from_handle: `orca_session_id:${SESSION}`, run_id: 'r2' }, { id: 'm3', from_handle: 'term_a', run_id: 'r1' } - ] + ], + senderName: (party) => (party.terminalHandle === 'term_a' ? 'Build tab' : null) }) expect(source).toEqual({ kind: 'agent', senders: [ - { party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null } }, + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Build tab' + }, { party: { address: `orca_session_id:${SESSION}`, terminalHandle: null, orcaSessionId: SESSION - } + }, + name: null } ], orchestration: { @@ -40,4 +45,22 @@ describe('who delivered mail is from', () => { } }) }) + + it('bounds a snapshot name, and a naming failure costs only the name', () => { + const named = (senderName: (party: unknown) => string | null) => + structuredMailSource({ + db: null, + mailboxHandle: 'run:r1', + dispatchId: null, + batch: [{ id: 'm1', from_handle: 'term_a', run_id: 'r1' }], + senderName + }).senders[0]?.name + expect(named(() => ` Build\n${'x'.repeat(400)}`)).toHaveLength(200) + expect(named(() => ' ')).toBeNull() + expect( + named(() => { + throw new Error('records unavailable') + }) + ).toBeNull() + }) }) diff --git a/src/main/runtime/orchestration/structured-mail-source.ts b/src/main/runtime/orchestration/structured-mail-source.ts index ad26c3b8be05..28d6f6142df0 100644 --- a/src/main/runtime/orchestration/structured-mail-source.ts +++ b/src/main/runtime/orchestration/structured-mail-source.ts @@ -5,25 +5,31 @@ */ import { parseOrcaSessionAddress } from '../../../shared/orca-session-address' -import type { - AgentMessageSource, - AgentMessageSender +import { + agentMessageSenderName, + type AgentMessageSource, + type AgentMessageSender } from '../../../shared/agent-session-message-source' import type { MessageRow, OrchestrationDb } from './db' import { resolveOrchestrationParty } from './orchestration-party' export type MailSourceMessage = Pick +/** A name Orca controls for a party (never an agent-set title); null when it has none. */ +export type SenderNameResolver = (party: AgentMessageSender['party']) => string | null + export function structuredMailSource(input: { db: OrchestrationDb | null mailboxHandle: string dispatchId: string | null 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, { party: senderParty(address, input.db) }) + const party = senderParty(address, input.db) + senders.set(address, { party, name: snapshotName(input.senderName, party) }) } } return { @@ -51,3 +57,15 @@ function senderParty(address: string, db: OrchestrationDb | null): AgentMessageS return { address, terminalHandle: null, orcaSessionId: parseOrcaSessionAddress(address) } } } + +function snapshotName( + senderName: SenderNameResolver, + party: AgentMessageSender['party'] +): string | null { + try { + return agentMessageSenderName(senderName(party)) + } catch { + // A name is a label, never a reason the mail is not delivered. + return null + } +} 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 46090094c0d2..fb78b74a3763 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -73,6 +73,7 @@ function harness(options: { resolveStructuredTarget: (mailboxHandle) => mailboxHandle === mailbox ? { sessionId: IDENTITY.sessionId, dispatchId } : null, getCliCommand: () => 'orca-dev', + senderName: (party) => (party.address === 'term_coord' ? 'Coordinator' : null), host: { readSessionFacts: async () => (attached ? { submissions } : null), currentFence: () => 4, @@ -170,9 +171,9 @@ describe('structured mailbox pointer delivery', () => { expect(send.mock.calls[0]![0].body.blocks[0]).toMatchObject({ text: expect.stringContaining('orca-dev orchestration check') }) - expect(send.mock.calls[0]![0].source).toMatchObject({ + expect(send.mock.calls[0]![0].body.from).toMatchObject({ kind: 'agent', - senders: [{ party: { address: 'term_coord' } }], + senders: [{ party: { address: 'term_coord' }, name: 'Coordinator' }], orchestration: { message: 'mail-notice', mailbox: 'dispatch:d1', @@ -422,6 +423,7 @@ describe('forgetting one settled worker', () => { : null }, getCliCommand: () => 'orca', + senderName: () => null, host: { readSessionFacts: async () => (attached ? { submissions: [] } : null), currentFence: () => 4, diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index f442c520d4bf..490b2e0e3ec8 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -13,7 +13,6 @@ */ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import type { MessageRow, OrchestrationDb } from './db' import { formatMessagePointer } from './formatter' import type { OrchestrationCliCommand } from './cli-command' @@ -25,7 +24,7 @@ import { resolveStructuredPointerOperation, type StructuredPointerSubmission } from './structured-pointer-operation-id' -import { structuredMailSource } from './structured-mail-source' +import { structuredMailSource, type SenderNameResolver } from './structured-mail-source' import { retainReasonForDispatch, structuredDispatchDelivered, @@ -67,8 +66,8 @@ export type StructuredMailboxPointerHost = { dispatchId: string | null operationId: string expectedRuntimeFence: number + /** Names its senders as `from`. */ body: AgentJournalMessageItem - source: AgentMessageSource }) => Promise /** Current lease fence; `null` when no record backs the session any more. */ currentFence: (sessionId: string) => number | null @@ -86,6 +85,8 @@ type StructuredPointerDeliveryDependencies StructuredPointerTarget | null /** The CLI name the PTY lane types for a local agent, so both lanes send the same pointer. */ getCliCommand: () => OrchestrationCliCommand + /** What Orca calls a sender now, snapshotted onto the message; null when it has no name. */ + senderName: SenderNameResolver host: StructuredMailboxPointerHost onRetain?: (input: { mailboxHandle: string @@ -219,7 +220,14 @@ export class OrchestrationStructuredMailboxPointerDelivery< type: 'text', text: formatMessagePointer(unread.length, mailboxHandle, this.deps.getCliCommand()).trim() } - ] + ], + from: structuredMailSource({ + db, + mailboxHandle, + dispatchId: target.dispatchId, + batch: unread, + senderName: this.deps.senderName + }) } const staged = unread.map((message) => message.id) const operation = resolveStructuredPointerOperation({ @@ -247,13 +255,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< dispatchId: target.dispatchId, operationId: operation.operationId, expectedRuntimeFence: fence, - body, - source: structuredMailSource({ - db, - mailboxHandle, - dispatchId: target.dispatchId, - batch: unread - }) + body }) if (outcome.kind === 'unattached') { this.retain(mailboxHandle, sessionId, 'session-not-attached', reservedTypes) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index 1d1700e76bce..fb0b8313cfd5 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -121,7 +121,7 @@ describe('structured mailbox pointer host', () => { it('asks a busy chat to queue the pointer as a card, with who it is from', async () => { const send = vi.fn( - async (_caller: unknown, _payload: { delivery?: string; source?: unknown }) => ({ + async (_caller: unknown, _payload: { delivery?: string; body?: unknown }) => ({ ok: true, value: { clientMessageId: 'op1', @@ -136,13 +136,12 @@ describe('structured mailbox pointer host', () => { dispatchId: 'd1', operationId: 'op1', expectedRuntimeFence: 1, - body: { kind: 'message', role: 'user', blocks: [] }, - source: NOTICE_SOURCE + body: { kind: 'message', role: 'user', blocks: [], from: NOTICE_SOURCE } }) ).resolves.toEqual({ kind: 'queued' }) expect(send.mock.calls[0]![1]).toMatchObject({ delivery: 'queue-if-active', - source: NOTICE_SOURCE + body: { from: NOTICE_SOURCE } }) }) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index 7bb79d01e3e3..f81ef2c229f3 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -103,7 +103,6 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo body: input.body, // As a person's message is: a busy chat queues it as a card, sent when the turn ends. delivery: 'queue', - source: input.source, operationId: input.operationId, expectedRuntimeFence: input.expectedRuntimeFence } diff --git a/src/main/runtime/rpc/methods/orchestration/caller-show.ts b/src/main/runtime/rpc/methods/orchestration/caller-show.ts index c8284dc4d108..f206ed9d1c07 100644 --- a/src/main/runtime/rpc/methods/orchestration/caller-show.ts +++ b/src/main/runtime/rpc/methods/orchestration/caller-show.ts @@ -1,5 +1,6 @@ import type { OrchestrationCallerShowResult, + OrchestrationPartyLocationResult, OrchestrationSessionAddressResult } from '../../../../../shared/orchestration-caller-status' import { @@ -7,9 +8,14 @@ import { isOrcaSessionId } from '../../../../../shared/orca-session-address' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../../../shared/orchestration-session-caller-codes' -import { SessionAddressParams } from '../../../../../shared/rpc-contract/orchestration-params' +import { + PartyLocationParams, + SessionAddressParams +} from '../../../../../shared/rpc-contract/orchestration-params' import { OrchestrationError } from '../../../orchestration/orchestration-error' import { resolveOrcaSessionParty } from '../../../orchestration/orchestration-party' +import { locateOrchestrationParty } from '../../../orchestration/orchestration-party-location' +import { readAgentSessionRecordStore } from '../../../orchestration/structured-session-lineage' import { defineMethod } from '../../core' export const ORCHESTRATION_CALLER_METHODS = [ @@ -39,5 +45,17 @@ export const ORCHESTRATION_CALLER_METHODS = [ const party = resolveOrcaSessionParty(params.sessionId, runtime.getOrchestrationDb()) return { orcaSessionId: formatOrcaSessionAddress(party.orcaSessionId) } } + }), + defineMethod({ + name: 'orchestration.partyLocation', + params: PartyLocationParams, + // Why host-side: a chat sender's live session follows its `/clear` lineage on this host. + handler: (params, { runtime }): OrchestrationPartyLocationResult => ({ + location: locateOrchestrationParty(params.address, { + db: runtime.getOrchestrationDb(), + records: readAgentSessionRecordStore(), + terminalHandleForPaneKey: (paneKey) => runtime.getTerminalHandleForPaneKey(paneKey) + }) + }) }) ] diff --git a/src/main/runtime/rpc/orchestration-party-addressing.test.ts b/src/main/runtime/rpc/orchestration-party-addressing.test.ts index ddc5711c8274..a6ffd3ea5ea5 100644 --- a/src/main/runtime/rpc/orchestration-party-addressing.test.ts +++ b/src/main/runtime/rpc/orchestration-party-addressing.test.ts @@ -21,6 +21,7 @@ import { sessionRecord, WORKER_HANDLE, WORKER_PANE, + WORKSPACE_X, type SessionCallerHarness } from './orchestration-session-caller-test-fixture' @@ -106,7 +107,8 @@ describe('every target param resolves both spellings of a party to one canonical 'orchestration.ask', 'orchestration.dispatch', 'orchestration.inbox', - 'orchestration.sessionAddress' + 'orchestration.sessionAddress', + 'orchestration.partyLocation' ].sort() ) }) @@ -218,6 +220,28 @@ describe('every target param resolves both spellings of a party to one canonical orcaSessionId: ADDRESS_Y }) }) + + it.each(WORKER_SPELLINGS)( + 'partyLocation: a worker at %s opens as its chat', + async (_l, address) => { + expect(await as(undefined, 'orchestration.partyLocation', { address })).toEqual({ + location: { kind: 'chat', sessionId: SESSION_Y, worktreeId: WORKSPACE_X } + }) + } + ) + + it('partyLocation: a dispatch opens as its assignee, a terminal as itself, a lost one as nothing', async () => { + const dispatchId = assignWorker(await chatRun()) + expect( + await as(undefined, 'orchestration.partyLocation', { address: `dispatch:${dispatchId}` }) + ).toEqual({ location: { kind: 'chat', sessionId: SESSION_Y, worktreeId: WORKSPACE_X } }) + expect(await as(undefined, 'orchestration.partyLocation', { address: 'term_peer' })).toEqual({ + location: { kind: 'terminal', handle: 'term_peer' } + }) + expect( + await as(undefined, 'orchestration.partyLocation', { address: 'dispatch:gone' }) + ).toEqual({ location: null }) + }) }) describe('a /clear-ed chat is shown the Orca session ID it had before the clear', () => { diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index 27414b3c1d74..38e26730a1e4 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -38,7 +38,14 @@ vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry' })) // Fields that can name a party: the caller in ORCHESTRATION_CALLER_PARAM, a target in ORCHESTRATION_TARGET_PARAM. -const PARTY_NAMING_FIELDS = ['to', 'from', 'terminal', 'callerTerminalHandle', 'sessionId'] as const +const PARTY_NAMING_FIELDS = [ + 'to', + 'from', + 'terminal', + 'callerTerminalHandle', + 'sessionId', + 'address' +] as const // `method field` pairs with such a field that is neither, so never resolves as a party. const NAMES_NO_RESOLVED_PARTY: Readonly> = { 'orchestration.run from': 'retired; refused before any handler', @@ -95,9 +102,9 @@ describe('orchestration session callers at the dispatch entry', () => { }) .sort() - // The population: 43 registered methods carrying 27 party-naming fields. - expect(registry.size).toBe(43) - expect(partyNaming).toHaveLength(27) + // The population: 44 registered methods carrying 28 party-naming fields. + expect(registry.size).toBe(44) + expect(partyNaming).toHaveLength(28) expect(partyNaming).toEqual( [ ...Object.entries(ORCHESTRATION_CALLER_PARAM).map( diff --git a/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts b/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts index 104f767e333f..68cf83bb0ebc 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts @@ -78,10 +78,14 @@ describe("a busy chat's orchestration pointer waits in its queue", () => { expect(chat.turns).toHaveLength(1) const [card] = queuedRows() const [mail] = db.getAllMessages(`run:${runId}`) - expect(card?.source).toEqual({ + const from = { kind: 'agent', senders: [ - { party: { address: 'term_worker', terminalHandle: 'term_worker', orcaSessionId: null } } + { + party: { address: 'term_worker', terminalHandle: 'term_worker', orcaSessionId: null }, + // No tab this runtime knows of names it, and an agent-set title never does. + name: null + } ], orchestration: { message: 'mail-notice', @@ -89,11 +93,18 @@ describe("a busy chat's orchestration pointer waits in its queue", () => { dispatchId: null, messages: [{ messageId: mail!.id, runId, from: 'term_worker' }] } - }) + } + // On the card's body: the turn the queue sends carries it, and the provider never sees it. + expect(card?.body.from).toEqual(from) await endTurn() await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT) expect(turnText(chat.turns[1]!)).toBe(ptyPointer(`run:${runId}`)) + expect(JSON.stringify(chat.turns[1])).not.toContain('term_worker') + const sent = (await host.journalSnapshot(COORDINATOR)).items.filter( + (item) => item.body.kind === 'message' && item.body.from + ) + expect(sent.map((item) => item.body.kind === 'message' && item.body.from)).toEqual([from]) expect(await queuedCardTexts()).toEqual([]) await settleTurn(COORDINATOR, 1) await idleEdgesSettled() diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx new file mode 100644 index 000000000000..c2c684925f5f --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentMessageSource } from '../../../../shared/agent-session-message-source' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { MessageRow } from './NativeChatMessageRow' +import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard' +import { TooltipProvider } from '@/components/ui/tooltip' + +const openAgentMessageSender = vi.hoisted(() => vi.fn(async () => {})) +vi.mock('@/lib/open-agent-message-sender', () => ({ openAgentMessageSender })) + +afterEach(() => { + cleanup() + openAgentMessageSender.mockClear() +}) + +const CODER = { address: 'term_coder', terminalHandle: 'term_coder', orcaSessionId: null } + +function from(senders: { address: string; name: string | null }[]): AgentMessageSource { + return { + kind: 'agent', + senders: senders.map(({ address, name }) => ({ + party: { address, terminalHandle: address, orcaSessionId: null }, + name + })), + orchestration: null + } +} + +function renderUserMessage(source?: AgentMessageSource, worktreeId: string | null = 'wt-chat') { + const message: NativeChatMessage = { + id: 'message', + role: 'user', + timestamp: 0, + source: 'transcript', + blocks: [{ type: 'text', text: 'You have 1 orchestration message.' }], + ...(source ? { from: source } : {}) + } + return render( + + ) +} + +describe("another agent's message in the transcript", () => { + it('reads as a left-aligned message from its sender, not as the person’s bubble', () => { + const { container } = renderUserMessage(from([{ address: CODER.address, name: 'Coder' }])) + expect(screen.getByText('Message from')).toBeInTheDocument() + const row = container.firstElementChild + expect(row).toHaveClass('items-start') + expect(row).not.toHaveClass('items-end') + expect(container.querySelector('.bg-chat-user-surface')).toBeNull() + expect(screen.getByText('You have 1 orchestration message.')).toBeInTheDocument() + }) + + it("opens the sender from its name, on the chat's own host", () => { + renderUserMessage(from([{ address: CODER.address, name: 'Coder' }])) + fireEvent.click(screen.getByRole('button', { name: 'Coder' })) + expect(openAgentMessageSender).toHaveBeenCalledWith(CODER, 'wt-chat') + }) + + it('names each sender, an unnamed one as an agent, and counts the rest', () => { + renderUserMessage( + from([ + { address: 'term_a', name: 'A' }, + { address: 'term_b', name: null }, + { address: 'term_c', name: 'C' }, + { address: 'term_d', name: 'D' } + ]) + ) + expect(screen.getByRole('button', { name: 'A' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'an agent' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'C' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'D' })).not.toBeInTheDocument() + expect(screen.getByText('+1')).toBeInTheDocument() + }) + + it('shows the name as plain text where there is no chat to open it from', () => { + renderUserMessage(from([{ address: CODER.address, name: 'Coder' }]), null) + expect(screen.getByText('Coder')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Coder' })).not.toBeInTheDocument() + }) + + it("leaves the person's own message as their right-aligned bubble", () => { + const { container } = renderUserMessage() + expect(screen.queryByText('Message from')).not.toBeInTheDocument() + expect(container.firstElementChild).toHaveClass('items-end') + expect(container.querySelector('.bg-chat-user-surface')).not.toBeNull() + }) +}) + +describe("another agent's queued card", () => { + it('names who it is from on a line the card’s own controls keep their clicks from', () => { + render( + + + + ) + expect(screen.getByText('From Coder, an agent')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Coder/ })).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx new file mode 100644 index 000000000000..f4566af261f4 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx @@ -0,0 +1,48 @@ +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { openAgentMessageSender } from '@/lib/open-agent-message-sender' +import { + agentMessageSendersShown, + type AgentMessageSource +} from '../../../../shared/agent-session-message-source' +import { agentMessageSenderLabel } from './native-chat-agent-message-sender-label' + +/** "Message from " over another agent's message; each name opens that agent. Plain text + * where the transcript has no chat to resolve the sender against. */ +export function NativeChatAgentMessageSenders({ + from, + chatWorktreeId +}: { + from: AgentMessageSource + chatWorktreeId: string | null +}): React.JSX.Element { + const { shown, more } = agentMessageSendersShown(from) + return ( +
+ {translate('components.native-chat.agentMessage.messageFrom', 'Message from')} + {shown.length === 0 ? ( + + {translate('components.native-chat.agentMessage.unnamedSender', 'an agent')} + + ) : null} + {shown.map((sender) => + chatWorktreeId ? ( + + ) : ( + + {agentMessageSenderLabel(sender)} + + ) + )} + {more > 0 ? +{more} : null} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 529b0c024c8f..18ce366f3f51 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -15,6 +15,7 @@ import { NativeChatCodeBlock } from './NativeChatCodeBlock' import { NativeChatNoticeRow } from './NativeChatNoticeRow' import { NativeChatCopyButton } from './NativeChatCopyButton' import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' +import { NativeChatAgentMessageSenders } from './NativeChatAgentMessageSenders' import { NativeChatAgentControls, NativeChatImageAttachments, @@ -175,10 +176,29 @@ export const MessageRow = memo(function MessageRow({ } if (isUser) { + // Another agent's message is the agent's turn input too, but is not the person's: it reads + // left-aligned under its sender rather than as their bubble. + const from = message.from return ( -
+
+ {from ? ( + + ) : null} {/* A distinct surface separates the user's prompt from the assistant's prose. */} -
+
{markdown ? ( <> )}
+ {card.from ? ( +

+ {queuedCardSenderLine(card.from)} +

+ ) : null}

{card.text}

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 new file mode 100644 index 000000000000..ebf50ef3e2f5 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts @@ -0,0 +1,24 @@ +import { translate } from '@/i18n/i18n' +import { + agentMessageSendersShown, + type AgentMessageSender, + type AgentMessageSource +} from '../../../../shared/agent-session-message-source' + +function unnamedSenderLabel(): string { + return translate('components.native-chat.agentMessage.unnamedSender', 'an agent') +} + +export function agentMessageSenderLabel(sender: AgentMessageSender): string { + return sender.name ?? unnamedSenderLabel() +} + +/** A queued card's plain "From " line; the card's own controls own its clicks. */ +export function queuedCardSenderLine(from: AgentMessageSource): string { + const { shown, more } = agentMessageSendersShown(from) + const names = [...new Set(shown.map(agentMessageSenderLabel))] + const listed = names.length > 0 ? names.join(', ') : unnamedSenderLabel() + return translate('components.native-chat.queuedMessages.from', 'From {{names}}', { + names: more > 0 ? `${listed} +${more}` : listed + }) +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 995d80247699..eaae4c92635b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -231,3 +231,25 @@ describe('queued message cards', () => { expect(ids(outboxOutsideQueuedCards(sent, [], true, unknown))).toEqual(['plain']) }) }) + +describe("another agent's card", () => { + it('carries who it is from, read through the shared reader', () => { + const from = { + kind: 'agent' as const, + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: null + } + const agentDraft = draft('a', 1) + const cards = projectQueuedMessageCards( + [{ ...agentDraft, body: { ...agentDraft.body, from } }, draft('b', 2)], + [], + { hasPendingPrompt: false } + ) + expect(cards.map((card) => card.from)).toEqual([from, undefined]) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index e9d1f394b9af..f49f3e32c1d4 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts @@ -4,6 +4,10 @@ import type { UnreadAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' +import { + readAgentMessageSource, + type AgentMessageSource +} from '../../../../shared/agent-session-message-source' import { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' import { structuredAgentSessionEntryAsksToQueue, @@ -37,6 +41,8 @@ export type QueuedMessageCard = { returnedReason?: string | null /** The typed fact the returned card's submission settled with; read like its `rejection`. */ returnedRejection?: UnreadAgentSessionFailureFact + /** Another agent's card: who sent it. */ + from?: AgentMessageSource } function queuedMessageCardText(body: AgentSessionQueuedMessage['body']): string { @@ -76,6 +82,7 @@ export function projectQueuedMessageCards( ? 'awaiting-answer' : 'turn' behindReturned = behindReturned || message.state === 'returned' + const from = readAgentMessageSource(message.body.from) return { messageId: message.messageId, position: message.position, @@ -86,7 +93,8 @@ export function projectQueuedMessageCards( ...(message.returnedReason !== undefined ? { returnedReason: message.returnedReason } : {}), ...(message.returnedRejection !== undefined ? { returnedRejection: message.returnedRejection } - : {}) + : {}), + ...(from ? { from } : {}) } }) } diff --git a/src/renderer/src/components/terminal-pane/stale-agent-row.ts b/src/renderer/src/components/terminal-pane/stale-agent-row.ts index b781f76940d2..3842010d3eee 100644 --- a/src/renderer/src/components/terminal-pane/stale-agent-row.ts +++ b/src/renderer/src/components/terminal-pane/stale-agent-row.ts @@ -10,22 +10,27 @@ export function dismissStaleAgentRowByKey(paneKey: string): void { store.dropAgentStatus(paneKey, { paneRemoved: true }) store.dismissRetainedAgent(paneKey) if (liveExisted || retainedExisted) { - toast.info( + showAgentPaneUnavailable( translate( - 'auto.components.terminal.pane.stale.agent.row.ad991ece5c', - "Agent's pane is no longer available." - ), - { - id: translate( - 'auto.components.terminal.pane.stale.agent.row.090d607412', - 'stale-agent-row-{{value0}}', - { value0: paneKey } - ) - } + 'auto.components.terminal.pane.stale.agent.row.090d607412', + 'stale-agent-row-{{value0}}', + { value0: paneKey } + ) ) } } +/** The one notice for a click that reaches an agent whose pane is gone. */ +export function showAgentPaneUnavailable(id?: string): void { + toast.info( + translate( + 'auto.components.terminal.pane.stale.agent.row.ad991ece5c', + "Agent's pane is no longer available." + ), + id === undefined ? undefined : { id } + ) +} + export function surfaceStaleAgentRow(tabId: string, leafId: string): void { dismissStaleAgentRowByKey(makePaneKey(tabId, leafId)) } diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts index ab210f163525..34c8175faf6a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts @@ -298,7 +298,7 @@ function getTerminalHandleFocusHint(showActions: boolean): string { : `${prefix}Ctrl+click to switch terminal` } -async function focusRuntimeTerminalHandle( +export async function focusRuntimeTerminalHandle( handle: string, runtimeEnvironmentId: string | null ): Promise { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 47fc41cca96b..75dc4f77c683 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18235,6 +18235,10 @@ "used": "Context: {{used}} tokens used, estimated from the last response.", "unreported": "Context usage is not available for this session." }, + "agentMessage": { + "messageFrom": "Message from", + "unnamedSender": "an agent" + }, "goal": { "placeholder": "Describe your goal, define measurable outcomes for best results", "clear": "Clear goal", @@ -18292,7 +18296,8 @@ "queuePausedRestarted": "Queue paused because Orca restarted", "queuePausedCleared": "Queue paused after you cleared the conversation", "queuePaused": "Queue paused", - "resume": "Resume" + "resume": "Resume", + "from": "From {{names}}" }, "structuredSessionHostDeclined": "Opened {{value0}} in a terminal", "structuredSessionHostDeclinedDescription": "This server can't run a {{value0}} chat in this workspace.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ee8ad0eb4f7e..b290f4d80f11 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17944,7 +17944,8 @@ "queuePausedRestarted": "Cola en pausa porque Orca se reinició", "queuePausedCleared": "Cola en pausa después de que borraste la conversación", "queuePaused": "Cola en pausa", - "resume": "Reanudar" + "resume": "Reanudar", + "from": "De {{names}}" }, "receipt": { "cancelled": "Cancelado", @@ -18048,6 +18049,10 @@ "used": "Contexto: {{used}} tokens usados, estimado a partir de la última respuesta.", "unreported": "El uso del contexto no está disponible para esta sesión." }, + "agentMessage": { + "messageFrom": "Mensaje de", + "unnamedSender": "un agente" + }, "goal": { "placeholder": "Describe tu objetivo y define resultados medibles para obtener mejores resultados", "clear": "Borrar objetivo", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c1d2b60f7199..72e9f0a21ad4 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18110,6 +18110,10 @@ "used": "Contexte : {{used}} tokens utilisés, estimation basée sur la dernière réponse.", "unreported": "L'utilisation du contexte n'est pas disponible pour cette session." }, + "agentMessage": { + "messageFrom": "Message de", + "unnamedSender": "un agent" + }, "goal": { "placeholder": "Décrivez votre objectif, définissez des résultats mesurables pour de meilleurs résultats", "clear": "Objectif clair", @@ -18161,7 +18165,8 @@ "queuePausedRestarted": "File d'attente en pause, car Orca a redémarré", "queuePausedCleared": "File d'attente en pause après l'effacement de la conversation", "queuePaused": "File d'attente en pause", - "resume": "Reprendre" + "resume": "Reprendre", + "from": "De {{names}}" }, "structuredSessionHostDeclined": "{{value0}} ouvert dans un terminal", "structuredSessionHostDeclinedDescription": "Ce serveur ne peut pas exécuter de discussion {{value0}} dans cet espace de travail.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c49555516f1c..e6a0862883ae 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18046,6 +18046,10 @@ "used": "コンテキスト: {{used}} トークン使用済み、最後の応答から推定。", "unreported": "このセッションではコンテキストの使用量を表示できません。" }, + "agentMessage": { + "messageFrom": "送信元:", + "unnamedSender": "Agent" + }, "goal": { "placeholder": "目標を説明し、最良の結果を得るために測定可能な結果を​​定義する", "clear": "明確な目標", @@ -18097,7 +18101,8 @@ "queuePausedRestarted": "Orca が再起動したため、キューを一時停止しました", "queuePausedCleared": "会話を消去したため、キューを一時停止しました", "queuePaused": "キューは一時停止中です", - "resume": "再開" + "resume": "再開", + "from": "送信元: {{names}}" }, "structuredSessionHostDeclined": "{{value0}} をターミナルで開きました", "structuredSessionHostDeclinedDescription": "このサーバーはこのワークスペースで {{value0}} チャットを実行できません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0fdd13b366c5..9860adfb3bbc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18046,6 +18046,10 @@ "used": "컨텍스트: {{used}} 토큰 사용, 마지막 응답 기준 추정치입니다.", "unreported": "이 세션에서는 컨텍스트 사용량을 확인할 수 없습니다." }, + "agentMessage": { + "messageFrom": "보낸 사람:", + "unnamedSender": "Agent" + }, "goal": { "placeholder": "목표를 설명하고, 최상의 결과를 위해 측정 가능한 결과를 정의하세요.", "clear": "명확한 목표", @@ -18097,7 +18101,8 @@ "queuePausedRestarted": "Orca가 다시 시작되어 대기열이 일시 중지되었습니다", "queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다", "queuePaused": "대기열이 일시 중지되었습니다", - "resume": "재개" + "resume": "재개", + "from": "보낸 사람: {{names}}" }, "structuredSessionHostDeclined": "{{value0}}을(를) 터미널에서 열었습니다", "structuredSessionHostDeclinedDescription": "이 서버는 이 워크스페이스에서 {{value0}} 채팅을 실행할 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a6e49bbbad4c..7a7512f1aa86 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18011,6 +18011,10 @@ "used": "上下文:已用 {{used}} token,根据上一次响应的估算值。", "unreported": "此会话无法提供上下文用量。" }, + "agentMessage": { + "messageFrom": "消息来自", + "unnamedSender": "一个智能体" + }, "goal": { "placeholder": "描述您的目标,定义可衡量的结果以获得最佳结果", "clear": "明确的目标", @@ -18062,7 +18066,8 @@ "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", "queuePausedCleared": "清除对话后,队列已暂停", "queuePaused": "队列已暂停", - "resume": "继续" + "resume": "继续", + "from": "来自 {{names}}" }, "structuredSessionHostDeclined": "已在终端中打开 {{value0}}", "structuredSessionHostDeclinedDescription": "此服务器无法在此工作区中运行 {{value0}} 聊天。", diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session.ts b/src/renderer/src/lib/activate-ai-vault-structured-session.ts index c04378e0a930..becd74facb61 100644 --- a/src/renderer/src/lib/activate-ai-vault-structured-session.ts +++ b/src/renderer/src/lib/activate-ai-vault-structured-session.ts @@ -44,10 +44,12 @@ type StructuredSessionActivationDeps = { hostCannotOpen: () => void } -const defaultDeps: StructuredSessionActivationDeps = { - activate: activateStructuredAgentSessionById, - refresh: refreshStructuredSessionTabs, - reveal: revealStructuredSession, +/** The feedback a click that opens a chat gives when it cannot, shared by every surface that + * opens one. */ +export const structuredSessionOpenFeedback: Pick< + StructuredSessionActivationDeps, + 'unavailable' | 'gone' | 'hostCannotOpen' +> = { unavailable: () => { toast.error( translate( @@ -81,6 +83,13 @@ const defaultDeps: StructuredSessionActivationDeps = { } } +const defaultDeps: StructuredSessionActivationDeps = { + activate: activateStructuredAgentSessionById, + refresh: refreshStructuredSessionTabs, + reveal: revealStructuredSession, + ...structuredSessionOpenFeedback +} + /** * @param session anything carrying the row's structured pointer — an Agent Session History row or * the drag payload built from one. Only `structuredSession` is read, and both surfaces must reach diff --git a/src/renderer/src/lib/open-agent-message-sender.test.ts b/src/renderer/src/lib/open-agent-message-sender.test.ts new file mode 100644 index 000000000000..f287c435363f --- /dev/null +++ b/src/renderer/src/lib/open-agent-message-sender.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type TabsByWorktree = Record + +const mocks = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn(), + focusRenderer: vi.fn(), + focusRuntime: vi.fn(), + paneUnavailable: vi.fn(), + activateChat: vi.fn(async () => true), + gone: vi.fn(), + hostCannotOpen: vi.fn(), + unavailable: vi.fn(), + tabs: new Map([['current', {}]]) +})) + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal>()), + callRuntimeRpc: mocks.callRuntimeRpc, + getActiveRuntimeTarget: ({ + activeRuntimeEnvironmentId + }: { + activeRuntimeEnvironmentId: string + }) => ({ kind: 'environment', environmentId: activeRuntimeEnvironmentId }) +})) +vi.mock('@/components/terminal-pane/terminal-handle-links', () => ({ + focusRendererTerminalHandle: mocks.focusRenderer, + focusRuntimeTerminalHandle: mocks.focusRuntime +})) +vi.mock('@/components/terminal-pane/stale-agent-row', () => ({ + showAgentPaneUnavailable: mocks.paneUnavailable +})) +vi.mock('./activate-ai-vault-structured-session', () => ({ + activateAiVaultStructuredSession: mocks.activateChat, + structuredSessionOpenFeedback: { + gone: mocks.gone, + hostCannotOpen: mocks.hostCannotOpen, + unavailable: mocks.unavailable + } +})) +vi.mock('./worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => 'env-host' +})) +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ unifiedTabsByWorktree: mocks.tabs.get('current') }) } +})) + +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { openAgentMessageSender } from './open-agent-message-sender' + +const HOST = { kind: 'environment', environmentId: 'env-host' } +const ROOT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +const chat = { address: `orca_session_id:${ROOT}`, terminalHandle: null, orcaSessionId: ROOT } +const terminal = { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null } +const dispatch = { address: 'dispatch:d1', terminalHandle: 'dispatch:d1', orcaSessionId: null } + +function failure(code: string): RuntimeRpcCallError { + return new RuntimeRpcCallError({ + id: 'rpc_1', + ok: false, + error: { code, message: code }, + _meta: { runtimeId: 'runtime_1' } + }) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.tabs.set('current', {}) +}) + +describe("opening a message's sender", () => { + it('focuses a terminal sender through the terminal-handle path, with no lookup', async () => { + mocks.focusRenderer.mockReturnValue(true) + await openAgentMessageSender(terminal, 'wt-chat') + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(mocks.focusRenderer).toHaveBeenCalledWith('term_a', 'env-host') + expect(mocks.focusRuntime).not.toHaveBeenCalled() + }) + + it("asks the chat's host to focus a terminal this window does not show, and says so when it is gone", async () => { + mocks.focusRenderer.mockReturnValue(false) + mocks.focusRuntime.mockRejectedValue(new Error('terminal_not_found')) + await openAgentMessageSender(terminal, 'wt-chat') + expect(mocks.focusRuntime).toHaveBeenCalledWith('term_a', 'env-host') + expect(mocks.paneUnavailable).toHaveBeenCalledTimes(1) + }) + + it("opens a chat sender at its live session through the open-chat flow, asking the chat's host", async () => { + mocks.callRuntimeRpc.mockResolvedValue({ + location: { kind: 'chat', sessionId: 'live-session', worktreeId: 'wt-sender' } + }) + await openAgentMessageSender(chat, 'wt-chat') + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(HOST, 'orchestration.partyLocation', { + address: chat.address + }) + expect(mocks.activateChat).toHaveBeenCalledWith({ + structuredSession: { workspaceId: 'wt-sender', sessionId: 'live-session' } + }) + }) + + it("resolves a dispatch sender on the host, to its assignee's terminal", async () => { + mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'terminal', handle: 'term_w' } }) + mocks.focusRenderer.mockReturnValue(true) + await openAgentMessageSender(dispatch, 'wt-chat') + expect(mocks.focusRenderer).toHaveBeenCalledWith('term_w', 'env-host') + }) + + it('gives the existing feedback for a sender the host no longer knows', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ location: null }) + await openAgentMessageSender(chat, 'wt-chat') + expect(mocks.gone).toHaveBeenCalledTimes(1) + await openAgentMessageSender(dispatch, 'wt-chat') + expect(mocks.paneUnavailable).toHaveBeenCalledTimes(1) + expect(mocks.activateChat).not.toHaveBeenCalled() + }) + + it('says the host could not be reached rather than that the sender is gone', async () => { + mocks.callRuntimeRpc.mockRejectedValue(new Error('socket closed')) + await openAgentMessageSender(chat, 'wt-chat') + expect(mocks.unavailable).toHaveBeenCalledTimes(1) + expect(mocks.gone).not.toHaveBeenCalled() + }) + + it('on a host before the lookup, opens a chat this window shows under its id, else asks for an update', async () => { + mocks.callRuntimeRpc.mockRejectedValue(failure('method_not_found')) + mocks.tabs.set('current', { 'wt-sender': [{ contentType: 'agent-session', entityId: ROOT }] }) + await openAgentMessageSender(chat, 'wt-chat') + expect(mocks.activateChat).toHaveBeenCalledWith({ + structuredSession: { workspaceId: 'wt-sender', sessionId: ROOT } + }) + mocks.tabs.set('current', {}) + await openAgentMessageSender(chat, 'wt-chat') + expect(mocks.hostCannotOpen).toHaveBeenCalledTimes(1) + }) + + it('runs one open per sender at a time', async () => { + let answer: (value: unknown) => void = () => {} + mocks.callRuntimeRpc.mockReturnValue(new Promise((resolve) => (answer = resolve))) + const first = openAgentMessageSender(chat, 'wt-chat') + const second = openAgentMessageSender(chat, 'wt-chat') + answer({ location: null }) + await Promise.all([first, second]) + expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/open-agent-message-sender.ts b/src/renderer/src/lib/open-agent-message-sender.ts new file mode 100644 index 000000000000..ba76a9ea19e1 --- /dev/null +++ b/src/renderer/src/lib/open-agent-message-sender.ts @@ -0,0 +1,125 @@ +// Opening the agent a chat message is from: its chat through the open-chat flow, or its terminal +// through the terminal-handle link's path, with the feedback those already give when they cannot. + +import type { AgentMessageSender } from '../../../shared/agent-session-message-source' +import type { + OrchestrationPartyLocation, + OrchestrationPartyLocationResult +} from '../../../shared/orchestration-caller-status' +import { useAppStore } from '@/store' +import { + callRuntimeRpc, + getActiveRuntimeTarget, + RuntimeRpcCallError, + type RuntimeClientTarget +} from '@/runtime/runtime-rpc-client' +import { + focusRendererTerminalHandle, + focusRuntimeTerminalHandle +} from '@/components/terminal-pane/terminal-handle-links' +import { showAgentPaneUnavailable } from '@/components/terminal-pane/stale-agent-row' +import { + activateAiVaultStructuredSession, + structuredSessionOpenFeedback +} from './activate-ai-vault-structured-session' +import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' + +type Party = AgentMessageSender['party'] + +const opensInFlight = new Map>() + +/** `chatWorktreeId`: the chat showing the message. The sender lives on that chat's host, so every + * call goes to that host. */ +export function openAgentMessageSender(party: Party, chatWorktreeId: string): Promise { + const key = `${chatWorktreeId}\0${party.address}` + const inFlight = opensInFlight.get(key) + if (inFlight) { + return inFlight + } + const opening = openSender(party, chatWorktreeId).finally(() => opensInFlight.delete(key)) + opensInFlight.set(key, opening) + return opening +} + +async function openSender(party: Party, chatWorktreeId: string): Promise { + const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), chatWorktreeId) + const located = await locateSender( + party, + getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }) + ) + if (located === 'host-cannot-open') { + structuredSessionOpenFeedback.hostCannotOpen() + return + } + if (located === 'unreachable') { + structuredSessionOpenFeedback.unavailable() + return + } + if (located === null) { + if (party.orcaSessionId) { + structuredSessionOpenFeedback.gone() + } else { + showAgentPaneUnavailable() + } + return + } + if (located.kind === 'chat') { + await activateAiVaultStructuredSession({ + structuredSession: { workspaceId: located.worktreeId, sessionId: located.sessionId } + }) + return + } + if (focusRendererTerminalHandle(located.handle, environmentId)) { + return + } + try { + await focusRuntimeTerminalHandle(located.handle, environmentId) + } catch { + showAgentPaneUnavailable() + } +} + +async function locateSender( + party: Party, + host: RuntimeClientTarget +): Promise { + // A terminal is its own handle: no lookup, as the terminal-handle link resolves one. + if (party.orcaSessionId === null && isPlainTerminalHandle(party.terminalHandle)) { + return { kind: 'terminal', handle: party.terminalHandle } + } + try { + const result = await callRuntimeRpc( + host, + 'orchestration.partyLocation', + { address: party.address } + ) + return result.location + } catch (error) { + if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { + return locateOnOlderHost(party) + } + return 'unreachable' + } +} + +function isPlainTerminalHandle(handle: string | null): handle is string { + return handle !== null && !handle.startsWith('dispatch:') && !handle.startsWith('run:') +} + +/** A host that predates the lookup: a chat open here under its root id, as a chat that was + * never `/clear`ed is; otherwise only an update can open it. */ +function locateOnOlderHost(party: Party): OrchestrationPartyLocation | 'host-cannot-open' { + const sessionId = party.orcaSessionId + if (sessionId) { + const tabsByWorktree = useAppStore.getState().unifiedTabsByWorktree + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + if (tabs.some((tab) => tab.contentType === 'agent-session' && tab.entityId === sessionId)) { + return { kind: 'chat', sessionId, worktreeId } + } + } + return 'host-cannot-open' + } + return isPlainTerminalHandle(party.terminalHandle) + ? { kind: 'terminal', handle: party.terminalHandle } + : 'host-cannot-open' +} diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index b91afbbac4a7..93ffcda5e4f0 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -17,6 +17,7 @@ import type { NativeChatToolMetadata } from './native-chat-tool-identity' import type { AgentSessionContextUsage } from './agent-session-context-usage' import type { AgentSessionProviderHandle } from './agent-session-provider-handle' import type { NativeChatBlock, NativeChatRole } from './native-chat-types' +import type { AgentMessageSource } from './agent-session-message-source' export { type AgentType } @@ -110,6 +111,9 @@ export type AgentJournalMessageItem = { /** Present on a conversation command the user sent, such as `/compact`. The text is what the * user typed; this names the command so no reader parses it. Open like `sentAs`. */ command?: { name: string } + /** Present on a message another agent sent through Orca; absent, the person's. Host-written, + * outside every fingerprint, never sent to the provider. */ + from?: AgentMessageSource } export type AgentJournalToolCallState = 'running' | 'completed' | 'failed' diff --git a/src/shared/agent-session-message-source.test.ts b/src/shared/agent-session-message-source.test.ts new file mode 100644 index 000000000000..444e44eaa5b6 --- /dev/null +++ b/src/shared/agent-session-message-source.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { + agentMessageSendersShown, + normalizeAgentMessageBodyFrom, + readAgentMessageSource, + type AgentMessageSource +} from './agent-session-message-source' +import { testOrcaSessionId } from './orca-session-address-test-fixture' + +const SESSION = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' + +const FROM: AgentMessageSource = { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Build tab' + }, + { + party: { + address: `orca_session_id:${SESSION}`, + terminalHandle: null, + orcaSessionId: testOrcaSessionId(SESSION) + }, + name: null + } + ], + orchestration: { + message: 'mail-notice', + mailbox: 'run:r1', + dispatchId: null, + messages: [{ messageId: 'm1', runId: 'r1', from: 'term_a' }] + } +} + +describe("a message's sender, read through its one reader", () => { + it('reads absence as the person, and a valid sender back whole', () => { + expect(readAgentMessageSource(undefined)).toBeUndefined() + expect(readAgentMessageSource(structuredClone(FROM))).toEqual(FROM) + }) + + it('keeps an agent an agent when its orchestration kind is newer than this build', () => { + const newer = { ...FROM, orchestration: { message: 'task', taskId: 't1' } } + expect(readAgentMessageSource(newer)).toEqual({ ...FROM, orchestration: null }) + // A newer sender kind is still not the person. + expect(readAgentMessageSource({ ...FROM, kind: 'automation' })).toMatchObject({ + kind: 'agent' + }) + }) + + it('drops a sender it cannot read and bounds a name, keeping the rest', () => { + const read = readAgentMessageSource({ + ...FROM, + senders: [ + { party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: 'term_a' } }, + { party: { address: 'term_b', terminalHandle: 'term_b', orcaSessionId: null }, name: 7 }, + { + party: { address: 'term_c', terminalHandle: 'term_c', orcaSessionId: null }, + name: `a‮b\n${'x'.repeat(300)}` + } + ] + }) + expect(read?.senders.map(({ party, name }) => [party.address, name?.length ?? null])).toEqual([ + ['term_b', null], + ['term_c', 200] + ]) + expect(read?.senders[1]?.name?.startsWith('a b ')).toBe(true) + }) + + it("reads a value that is not a sender at all as the person's message", () => { + for (const value of [null, 'nobody', 42, [], { senders: [] }]) { + expect(readAgentMessageSource(value)).toBeUndefined() + } + }) + + it('normalizes a stored body in place, dropping what it cannot read', () => { + const kept: Record = { kind: 'message', from: { ...FROM, extra: 1 } } + normalizeAgentMessageBodyFrom(kept) + expect(kept.from).toEqual(FROM) + const dropped: Record = { kind: 'message', from: 'nobody' } + normalizeAgentMessageBodyFrom(dropped) + expect('from' in dropped).toBe(false) + }) + + it('names at most a few senders and counts the rest', () => { + const many: AgentMessageSource = { + ...FROM, + senders: Array.from({ length: 5 }, (_, index) => ({ + party: { address: `term_${index}`, terminalHandle: `term_${index}`, orcaSessionId: null }, + name: `Agent ${index}` + })) + } + const { shown, more } = agentMessageSendersShown(many) + expect(shown.map((sender) => sender.name)).toEqual(['Agent 0', 'Agent 1', 'Agent 2']) + expect(more).toBe(2) + }) +}) diff --git a/src/shared/agent-session-message-source.ts b/src/shared/agent-session-message-source.ts index f0ab9a71f139..72d61e294c6d 100644 --- a/src/shared/agent-session-message-source.ts +++ b/src/shared/agent-session-message-source.ts @@ -1,16 +1,22 @@ -// Who a chat message is from: the person at the composer, or another agent through Orca. -// Persisted with a queued card (`queued_messages.source_json`), so the chat can name each sender. +// Who a chat message is from when it is not the person at the composer: another agent, through +// Orca. Carried on the message body (`AgentJournalMessageItem.from`), outside every fingerprint. import { z } from 'zod' +import { normalizeAgentSessionConversationName } from './agent-session-conversation-name' import { isOrcaSessionId, type OrcaSessionId } from './orca-session-address' import type { OrchestrationPartyIdentity } from './orchestration-party-identity' +import { openEnum, salvagedField, salvagedOptional, salvagingArray } from './zod-salvage' /** * An agent a message is from, named by the orchestration database of the host that stores the - * message: the only host whose agents can send today. A relayed sender adds its host here. No pane - * key: it reads and consumes that agent's mailbox, so the host resolves it from the handle. + * message: a federated sender arrives as its `dispatch:` address there. No pane key: it reads + * and consumes that agent's mailbox, so the host resolves it from the address. */ -export type AgentMessageSender = Readonly<{ party: Omit }> +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 +}> /** One orchestration message a notice points at: its record, and its sender's `senders` address. */ export type OrchestrationMailMessage = Readonly<{ messageId: string; runId: string; from: string }> @@ -31,14 +37,14 @@ export type AgentMessageSource = Readonly<{ kind: 'agent' /** Every distinct sender of the messages it carries, in mail order. */ senders: readonly AgentMessageSender[] - orchestration: OrchestrationAgentMessage + /** Null when this build cannot read the kind a newer one wrote; the senders still stand. */ + orchestration: OrchestrationAgentMessage | null }> -export type AgentSessionMessageSource = Readonly<{ kind: 'user' }> | AgentMessageSource - -export const USER_MESSAGE_SOURCE: AgentSessionMessageSource = { kind: 'user' } - -const MESSAGE_SOURCE_VERSION = 1 +/** A sender name as stored: bounded and flattened like a conversation name, never raw. */ +export function agentMessageSenderName(value: unknown): string | null { + return normalizeAgentSessionConversationName(value) +} const orcaSessionIdSchema = z.custom( (value) => typeof value === 'string' && isOrcaSessionId(value) @@ -51,38 +57,57 @@ const mailNoticeSchema = z.object({ messages: z.array(z.object({ messageId: z.string(), runId: z.string(), from: z.string() })) }) -// Not strict: a newer build may add a field, which this one keeps no use for and must not reject. -const storedSourceSchema = z.discriminatedUnion('kind', [ - z.object({ v: z.literal(MESSAGE_SOURCE_VERSION), kind: z.literal('user') }), - z.object({ - v: z.literal(MESSAGE_SOURCE_VERSION), - kind: z.literal('agent'), - senders: z.array( - z.object({ - party: z.object({ - address: z.string(), - terminalHandle: z.string().nullable(), - orcaSessionId: orcaSessionIdSchema.nullable() - }) - }) - ), - orchestration: z.discriminatedUnion('message', [mailNoticeSchema]) - }) -]) +const senderSchema = z.object({ + party: z.object({ + address: z.string().min(1), + terminalHandle: z.string().nullable(), + orcaSessionId: orcaSessionIdSchema.nullable() + }), + name: z.unknown().transform(agentMessageSenderName) +}) + +// Read apart, so a malformed sender or an orchestration kind this build lacks never turns an +// agent's message into the person's. +const sourceSchema = z.object({ + kind: openEnum(['agent'], 'agent'), + senders: salvagedField('senders', salvagingArray(senderSchema), () => []), + orchestration: salvagedOptional( + 'orchestration', + z.discriminatedUnion('message', [mailNoticeSchema]) + ).transform((orchestration) => orchestration ?? null) +}) + +/** The one reader of a stored or published `from`: undefined (the person's) when it is absent + * or not an agent's at all. */ +export function readAgentMessageSource(value: unknown): AgentMessageSource | undefined { + if (value === undefined) { + return undefined + } + const parsed = sourceSchema.safeParse(value) + return parsed.success ? parsed.data : undefined +} -export function serializeAgentSessionMessageSource(source: AgentSessionMessageSource): string { - return JSON.stringify({ v: MESSAGE_SOURCE_VERSION, ...source }) +/** A message body's `from` replaced in place by what `readAgentMessageSource` reads of it, at a + * boundary that keeps the stored object. */ +export function normalizeAgentMessageBodyFrom(body: Record): void { + if (!('from' in body)) { + return + } + const from = readAgentMessageSource(body.from) + if (from) { + body.from = from + } else { + delete body.from + } } -/** - * The stored value read back. Absent (a card from before the column) is the person's: only the - * composer queued then. So is a value this build cannot read; either way it is sent as written. - */ -export function readAgentSessionMessageSource(stored: unknown): AgentSessionMessageSource { - const parsed = storedSourceSchema.safeParse(stored) - if (!parsed.success) { - return USER_MESSAGE_SOURCE +/** The senders a label names, and how many more it counts; one notice can carry many. */ +export function agentMessageSendersShown( + source: AgentMessageSource, + limit = 3 +): { shown: readonly AgentMessageSender[]; more: number } { + return { + shown: source.senders.slice(0, limit), + more: Math.max(0, source.senders.length - limit) } - const { v: _version, ...source } = parsed.data - return source } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index f48a31f7f38b..483783b1fc44 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -19,6 +19,7 @@ import type { } from './agent-session-journal-types' import type { AgentType } from './agent-status-types' import type { NativeChatToolMetadata } from './native-chat-tool-identity' +import type { AgentMessageSource } from './agent-session-message-source' export type { AgentType } @@ -222,6 +223,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { parentId?: string /** How a user message was delivered when it was not an ordinary prompt. */ sentAs?: AgentJournalMessageSendMode + /** On a user-role message another agent sent through Orca: who, as the journal recorded it. */ + from?: AgentMessageSource /** Accepted but not yet handed to the agent: drawn after everything the agent has done. */ queued?: true /** Shown as not sent: in no turn, so a newer turn's bar and clock never land on it. Drawn where diff --git a/src/shared/orchestration-caller-status.ts b/src/shared/orchestration-caller-status.ts index 3f701739b19b..d49581e26150 100644 --- a/src/shared/orchestration-caller-status.ts +++ b/src/shared/orchestration-caller-status.ts @@ -25,3 +25,11 @@ export type OrchestrationCallerShowResult = { caller: OrchestrationCallerSession /** `orchestration.sessionAddress`: a session's Orca session ID, its `/clear` root's. */ export type OrchestrationSessionAddressResult = { orcaSessionId: string } + +/** Where a party is now: a chat at its `/clear` lineage's live session, or a terminal. */ +export type OrchestrationPartyLocation = + | { kind: 'chat'; sessionId: string; worktreeId: string } + | { kind: 'terminal'; handle: string } + +/** `orchestration.partyLocation`: null when this host no longer knows where the party is. */ +export type OrchestrationPartyLocationResult = { location: OrchestrationPartyLocation | null } diff --git a/src/shared/rpc-contract/orchestration-params.ts b/src/shared/rpc-contract/orchestration-params.ts index 47d3edd1c04c..311d9ee30ab0 100644 --- a/src/shared/rpc-contract/orchestration-params.ts +++ b/src/shared/rpc-contract/orchestration-params.ts @@ -111,6 +111,11 @@ export const SessionAddressParams = z.object({ sessionId: requiredString('Missing sessionId') }) +/** A party's mailbox address, as a message's sender recorded it. */ +export const PartyLocationParams = z.object({ + address: requiredString('Missing address') +}) + export const DispatchShowParams = z.object({ task: OptionalString, preamble: OptionalBoolean, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index d6d81433e8e0..4172d4f4211f 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -384,6 +384,7 @@ import { DispatchParams, DispatchShowParams, InboxParams, + PartyLocationParams, ReplyParams, ResetParams, SessionAddressParams, @@ -1021,6 +1022,7 @@ export const RPC_PARAMS_BY_METHOD = { 'orchestration.gateList': GateListParams, 'orchestration.gateResolve': GateResolveParams, 'orchestration.inbox': InboxParams, + 'orchestration.partyLocation': PartyLocationParams, 'orchestration.reply': ReplyParams, 'orchestration.requestShow': RequestShowParams, 'orchestration.reset': ResetParams, diff --git a/src/shared/structured-agent-session-projection-sender.test.ts b/src/shared/structured-agent-session-projection-sender.test.ts new file mode 100644 index 000000000000..18a41c68dbd7 --- /dev/null +++ b/src/shared/structured-agent-session-projection-sender.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem, AgentJournalRenderItem } from './agent-session-journal-types' +import type { AgentMessageSource } from './agent-session-message-source' +import { projectStructuredItemToNativeChat } from './structured-agent-session-projection' + +const FROM: AgentMessageSource = { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: null +} + +function message(role: 'user' | 'assistant', from?: unknown): AgentJournalRenderItem { + const body: AgentJournalMessageItem = { + kind: 'message', + role, + blocks: [{ type: 'text', text: 'hi' }] + } + return { + itemId: `orca:submission:${role}`, + sequence: 1, + revision: 0, + observedAt: 1, + // A body as a client receives it: off the wire, from a host of any version. + body: from === undefined ? body : Object.assign(body, { from: structuredClone(from) }) + } +} + +describe("a message's sender, projected for every client", () => { + it('carries the sender of a user-role message another agent sent', () => { + expect(projectStructuredItemToNativeChat(message('user', FROM))?.from).toEqual(FROM) + }) + + it("leaves the person's message, and a value no reader can place, without one", () => { + expect(projectStructuredItemToNativeChat(message('user'))).not.toHaveProperty('from') + expect(projectStructuredItemToNativeChat(message('user', 'nobody'))).not.toHaveProperty('from') + expect(projectStructuredItemToNativeChat(message('assistant', FROM))).not.toHaveProperty('from') + }) +}) diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 0c2173c0a2dc..acba826c7a10 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -33,6 +33,7 @@ import { import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' import { sha256 } from './sha256' +import { readAgentMessageSource } from './agent-session-message-source' import { structuredAgentSessionStatusStartedAt } from './structured-agent-session-status-started-at' import { owesStructuredAgentSessionWork } from './structured-agent-session-owed-work' @@ -163,6 +164,8 @@ export function projectStructuredItemToNativeChat( // Reducer updates replace journal items, so unchanged rows keep their render caches. const projected = itemBlocks(item) const sentAs = item.body.kind === 'message' ? item.body.sentAs : undefined + // Read here too: a client's journal comes off the wire, from a host of any version. + const from = item.body.kind === 'message' ? readAgentMessageSource(item.body.from) : undefined const message: NativeChatMessage | null = projected ? { ...agentJournalItemRowOrigin(item), @@ -170,7 +173,8 @@ export function projectStructuredItemToNativeChat( role: projected.role, blocks: projected.blocks, // A send mode this build cannot name renders as an ordinary message. - ...(sentAs !== undefined && isAgentJournalMessageSendMode(sentAs) ? { sentAs } : {}) + ...(sentAs !== undefined && isAgentJournalMessageSendMode(sentAs) ? { sentAs } : {}), + ...(from && projected.role === 'user' ? { from } : {}) } : null projectedItems.set(item, message) diff --git a/src/shared/structured-agent-session-send-mutation.test.ts b/src/shared/structured-agent-session-send-mutation.test.ts new file mode 100644 index 000000000000..353186e4da11 --- /dev/null +++ b/src/shared/structured-agent-session-send-mutation.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from './agent-session-journal-types' +import type { AgentMessageSource } from './agent-session-message-source' +import { SendParams } from './rpc-contract/structured-agent-session-params' +import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' +import { + agentSessionSendBodyFingerprint, + structuredAgentSessionMessageSendMutation +} from './structured-agent-session-send-mutation' + +const BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'You have 1 orchestration message.' }] +} + +const FROM: AgentMessageSource = { + kind: 'agent', + senders: [ + { + party: { address: 'term_peer', terminalHandle: 'term_peer', orcaSessionId: null }, + name: 'Claude' + } + ], + orchestration: { message: 'mail-notice', mailbox: 'run:r1', dispatchId: null, messages: [] } +} + +describe('send fingerprints never cover the sender', () => { + it('keeps the digest every stored submission and draft already holds', () => { + // Pinned bytes: a stored fingerprint that changed would stop matching its provider echo. + expect(agentSessionSendBodyFingerprint('session-1', BODY)).toBe( + 'bf73e28368c0837ade16328692116535c6d97fc9924c70aa342a3cf18817d047' + ) + expect(agentSessionSendBodyFingerprint('session-1', BODY)).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: BODY } + }) + ) + }) + + it('fingerprints a body with a sender exactly as the same body without one', () => { + expect(agentSessionSendBodyFingerprint('session-1', { ...BODY, from: FROM })).toBe( + agentSessionSendBodyFingerprint('session-1', BODY) + ) + const send = (body: AgentJournalMessageItem) => + structuredAgentSessionMessageSendMutation({ + sessionId: 'session-1', + clientOperationId: 'op-1', + expectedRuntimeFence: 1, + body, + delivery: 'queue-if-active' + }) + const withSender = send({ ...BODY, from: FROM }) + expect(withSender.envelope.payloadFingerprint).toBe(send(BODY).envelope.payloadFingerprint) + // The sender still travels with the message itself. + expect(withSender.body.from).toEqual(FROM) + }) + + it('refuses a client send whose body names a sender', () => { + const params = (body: unknown) => ({ + envelope: { + sessionId: 'session-1', + clientOperationId: 'op-1', + expectedRuntimeFence: 1, + payloadFingerprint: 'a'.repeat(64) + }, + body + }) + expect(SendParams.safeParse(params(BODY)).success).toBe(true) + expect(SendParams.safeParse(params({ ...BODY, from: FROM })).success).toBe(false) + }) +}) diff --git a/src/shared/structured-agent-session-send-mutation.ts b/src/shared/structured-agent-session-send-mutation.ts index 420611ac0b93..ddefedb5ab54 100644 --- a/src/shared/structured-agent-session-send-mutation.ts +++ b/src/shared/structured-agent-session-send-mutation.ts @@ -1,4 +1,4 @@ -import type { AgentJournalMessageItem } from './agent-session-journal-types' +import type { AgentJournalItemBody, AgentJournalMessageItem } from './agent-session-journal-types' import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' @@ -8,6 +8,25 @@ export type StructuredAgentSessionSendMutation = { delivery?: 'queue-if-active' } +/** What every send fingerprint covers: the message without its sender, which never reaches the + * provider, so its echo still matches. A body with no sender hashes exactly as it always has. */ +export function agentSessionMessagePayload(body: AgentJournalMessageItem): AgentJournalMessageItem { + const { from: _sender, ...payload } = body + return payload +} + +/** The body-only hash a submission and a queued draft store, and a provider echo is matched by. */ +export function agentSessionSendBodyFingerprint( + sessionId: string, + body: AgentJournalItemBody +): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body: body.kind === 'message' ? agentSessionMessagePayload(body) : body } + }) +} + /** The `agentSession.send` arguments for one message. Typed rather than wire-shaped so a host * calling its own send path builds the same envelope a client would, fingerprint included. */ export function structuredAgentSessionMessageSendMutation(message: { @@ -18,7 +37,7 @@ export function structuredAgentSessionMessageSendMutation(message: { delivery?: 'queue-if-active' }): StructuredAgentSessionSendMutation { // `delivery` joins the OPERATION fingerprint exactly as the host digests it; never the body's. - const fields = { body: message.body, ...(message.delivery ? { delivery: message.delivery } : {}) } + const delivery = message.delivery ? { delivery: message.delivery } : {} return { envelope: { sessionId: message.sessionId, @@ -27,9 +46,10 @@ export function structuredAgentSessionMessageSendMutation(message: { payloadFingerprint: structuredAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId: message.sessionId, - fields + fields: { body: agentSessionMessagePayload(message.body), ...delivery } }) }, - ...fields + body: message.body, + ...delivery } } From eda094ac4b1c26d65051a9a70b4483f6bb946963 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:57:40 -0700 Subject: [PATCH 2/6] fix(native-chat): name and open an agent's message by what Orca shows for it Review fixes for showing who an agent message is from. - Names: a sender is named by its dispatch's task (local workers too, not only federated ones), else a chat by the label its tab shows, else a terminal by its tab's stored title, else its agent. The conversation name the snapshot read before has no writer, so every chat read " Chat". - One sender builder (agentMessageSender) and a public runtime naming object (orchestrationSenderNames) any send site can use; the terminal naming moves out of the unrelated runtime layer into its own module. - Opening a terminal sender always asks the chat's host: a terminal handle is issued per run, so the host finds an earlier run's terminal again through the pane its mail was sent from. The pane key never leaves the host. - Feedback follows what the host found: a gone chat or a gone pane by the kind it lost, "pane no longer available" only when the terminal answer proves it, and the existing "couldn't reach" / "needs a newer Orca" words otherwise. - Another agent's message is not offered rewind (it would put the agent's text in the person's composer), and a Codex rewind keeps the sender on the messages it retains. - The queued card and mobile name every sender, two that share a name included, and count in "+N" only those left out; the transcript row separates names with ", ". - The host no longer normalizes the sender when loading the journal or the queue: nothing on the host reads it, and clients read it through the one reader at their projections. --- .../mobile-agent-message-attribution.test.ts | 11 +- .../mobile-agent-message-attribution.ts | 3 +- .../journal-row-unusable-annotations.ts | 18 --- .../queued-message-store.test.ts | 8 +- .../queued-message-table.ts | 2 - ...ructured-rewind-retained-host-rows.test.ts | 43 +++++- .../structured-rewind-retained-host-rows.ts | 7 +- .../orca-runtime-stop-requested-pty-ids.ts | 35 ++--- .../orchestration-party-location.test.ts | 120 +++++++++++++++ .../orchestration-party-location.ts | 56 +++++-- .../orchestration-sender-location.test.ts | 133 ---------------- .../orchestration-sender-name.ts | 81 ++++++---- .../orchestration/structured-mail-source.ts | 16 +- .../rpc/methods/orchestration/caller-show.ts | 18 ++- .../orchestration-party-addressing.test.ts | 27 +++- ...runtime-orchestration-sender-names.test.ts | 119 +++++++++++++++ .../runtime-orchestration-sender-names.ts | 73 +++++++++ ...ctured-chat-coordinator-mail-queue.test.ts | 4 +- .../NativeChatAgentMessageRow.test.tsx | 29 +++- .../NativeChatAgentMessageSenders.tsx | 43 +++--- .../native-chat-agent-message-sender-label.ts | 5 +- .../native-chat-rewind-eligibility.ts | 4 +- .../use-native-chat-rewind.test.tsx | 17 +++ .../src/lib/open-agent-message-sender.test.ts | 137 ++++++++++++----- .../src/lib/open-agent-message-sender.ts | 143 ++++++++++++------ .../agent-session-message-source.test.ts | 10 -- src/shared/agent-session-message-source.ts | 23 ++- src/shared/orchestration-caller-status.ts | 8 +- .../rpc-contract/orchestration-params.ts | 6 +- 29 files changed, 825 insertions(+), 374 deletions(-) create mode 100644 src/main/runtime/orchestration/orchestration-party-location.test.ts delete mode 100644 src/main/runtime/orchestration/orchestration-sender-location.test.ts create mode 100644 src/main/runtime/runtime-orchestration-sender-names.test.ts create mode 100644 src/main/runtime/runtime-orchestration-sender-names.ts diff --git a/mobile/src/session/mobile-agent-message-attribution.test.ts b/mobile/src/session/mobile-agent-message-attribution.test.ts index 1e9d805ca23a..371758a525f1 100644 --- a/mobile/src/session/mobile-agent-message-attribution.test.ts +++ b/mobile/src/session/mobile-agent-message-attribution.test.ts @@ -19,13 +19,20 @@ describe('who another agent’s message is from, on mobile', () => { expect(agentMessageAttribution('From', undefined)).toBeNull() }) - it('names each sender once, an unnamed one as an agent, and counts the rest', () => { + it('names each sender, an unnamed one as an agent, and counts the rest', () => { expect(agentMessageAttribution('Message from', from(['Coder']))).toBe('Message from Coder') - expect(agentMessageAttribution('From', from([null, null]))).toBe('From an agent') expect(agentMessageAttribution('From', from([]))).toBe('From an agent') expect(agentMessageAttribution('From', from(['A', 'B', 'C', 'D']))).toBe('From A, B, C +1') }) + it('keeps two senders that share a name two, and counts only the senders it leaves out', () => { + expect(agentMessageAttribution('From', from(['Codex', 'Codex']))).toBe('From Codex, Codex') + expect(agentMessageAttribution('From', from([null, null]))).toBe('From an agent, an agent') + expect(agentMessageAttribution('From', from(['Codex', 'Codex', 'Codex', 'Codex']))).toBe( + 'From Codex, Codex, Codex +1' + ) + }) + it('puts the line on the queued card, read through the shared reader', () => { const body = { kind: 'message' as const, diff --git a/mobile/src/session/mobile-agent-message-attribution.ts b/mobile/src/session/mobile-agent-message-attribution.ts index 52037b3f59dc..8c1729c33375 100644 --- a/mobile/src/session/mobile-agent-message-attribution.ts +++ b/mobile/src/session/mobile-agent-message-attribution.ts @@ -16,6 +16,7 @@ export function agentMessageAttribution( return null } const { shown, more } = agentMessageSendersShown(source) - const names = [...new Set(shown.map((sender) => sender.name ?? UNNAMED_SENDER))] + // One name per sender, repeats included: two senders that share a name are still two. + const names = shown.map((sender) => sender.name ?? UNNAMED_SENDER) return `${lead} ${names.length > 0 ? names.join(', ') : UNNAMED_SENDER}${more > 0 ? ` +${more}` : ''}` } diff --git a/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts b/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts index e63653e2f33a..6c8811eb4a5b 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts @@ -2,7 +2,6 @@ // the row's content is checked (journal-row-schema.ts). import { isAdmissibleAgentSessionContextUsage } from '../../../shared/agent-session-context-usage-schema' -import { normalizeAgentMessageBodyFrom } from '../../../shared/agent-session-message-source' export function dropUnusableRowAnnotations(record: Record): void { dropUnusableProducerLinkage(record) @@ -16,23 +15,6 @@ export function dropUnusableRowAnnotations(record: Record): voi } } dropUnusableContextUsage(record) - normalizeMessageSenders(record) -} - -/** A message's sender read through its one reader: what it cannot read is dropped, and the - * message stays (an agent's with whatever senders read, else the person's). */ -function normalizeMessageSenders(record: Record): void { - const bodies = [ - record.kind === 'item' || record.kind === 'submission' ? record.body : undefined, - ...(record.kind === 'lifecycle-batch' && Array.isArray(record.mutations) - ? record.mutations.map((mutation) => (isPlainObject(mutation) ? mutation.body : undefined)) - : []) - ] - for (const body of bodies) { - if (isPlainObject(body) && body.kind === 'message') { - normalizeAgentMessageBodyFrom(body) - } - } } /** Linkage ids this build cannot trust, removed from a row it still keeps. diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index 94932406bb3e..873721ee0ae6 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -175,7 +175,7 @@ describe('draft rows', () => { expect(journal.queuedMessages.list()).toHaveLength(1) }) - it("keeps an agent card's sender on its body across reopen, read through the one reader", async () => { + it("keeps an agent card's body as written across reopen: the queue reads nothing of its sender", async () => { const from: AgentMessageSource = { kind: 'agent', senders: [ @@ -212,7 +212,7 @@ describe('draft rows', () => { await first.close() closeTestJournalHostDatabases() const db = new Database(journalDatabasePath(root)) - // A newer build's message kind, and a value no build writes. + // A newer build's message kind, and a value no build writes: carried as written, for clients to read. const setFrom = db.prepare( "UPDATE queued_messages SET body_json = json_set(body_json, '$.from', json(?)) WHERE message_id = ?" ) @@ -227,8 +227,8 @@ describe('draft rows', () => { const reopened = await open() expect(reopened.queuedMessages.list().map((row) => [row.messageId, row.body.from])).toEqual([ ['agent-card', from], - ['newer-kind', { ...from, orchestration: null }], - ['malformed', undefined] + ['newer-kind', { ...from, orchestration: { message: 'task', taskId: 't1' } }], + ['malformed', 'nobody'] ]) // That older table still takes new cards. await queueDraft(reopened, 'after') diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index 1558295c9cb5..a2bd9c3e05ab 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -13,7 +13,6 @@ import type { AgentJournalCursor, AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { normalizeAgentMessageBodyFrom } from '../../../shared/agent-session-message-source' import { rejectedDraftSettlement } from './journal-dispatch-settlement' import { readStoredRejectionFact } from './journal-dispatch-reducer' @@ -301,7 +300,6 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { try { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: body_json is written only by insertQueuedMessage from a schema-validated AgentJournalMessageItem. body = JSON.parse(record.body_json) as AgentJournalMessageItem - normalizeAgentMessageBodyFrom(body) } catch { // Our own writer stringified it; an unreadable body is corruption, and a // row we cannot re-materialize must not masquerade as an empty message. diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts index 3fb9495501e8..4462f1a9a9c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalItemBody, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import { AgentSessionRewindRecordSchema, type AgentSessionRewindRecord @@ -145,6 +148,44 @@ describe('rewind keeps each retained row attributed', () => { }) }) +describe("a rewind keeps another agent's messages its", () => { + const from = { + kind: 'agent' as const, + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Worker' + } + ], + orchestration: null + } + const user = (text: string): AgentJournalMessageItem => ({ + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] + }) + + it("puts the sender back on the provider's copy, which never carries it", () => { + const itemId = codexKey('turn-1', 0) + const merged = mergeRetainedHostLifecycleRows( + [retained(itemId, { ...user('You have 1 orchestration message.'), from })], + [providerItem(itemId, user('You have 1 orchestration message.'))] + ) + expect(merged).toHaveLength(1) + expect(merged[0]?.body).toMatchObject({ from }) + }) + + it("leaves the person's own message, and a provider row of another role, without one", () => { + const own = codexKey('turn-1', 0) + const answer = codexKey('turn-1', 1) + const merged = mergeRetainedHostLifecycleRows( + [retained(own, user('mine')), retained(answer, { ...user('hi'), from })], + [providerItem(own, user('mine')), providerItem(answer, prose('hi'))] + ) + expect(merged.map((row) => 'from' in row.body)).toEqual([false, false]) + }) +}) + describe('the rebuilt epoch item for a retained row', () => { it('passes a placeable scope and a known producer through', () => { expect( diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts index b1cbbe22b74a..bd7174e5076d 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts @@ -16,6 +16,7 @@ import type { import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { restoreRewindJournalBody } from './structured-rewind-journal-body' +import { withRecordedSender } from '../../../shared/agent-session-message-source' type RetainedRow = AgentSessionRewindRecord['retained'][number] @@ -93,13 +94,13 @@ export function retainedRowReplacement(row: RetainedRow): AgentJournalProducerLi } /** A provider item the old epoch held keeps that row's scope, producer and first-seen time; only - * its body is the provider's. */ + * its body is the provider's, and a user message keeps the sender the host recorded on it. */ function withHeldAttribution(item: RetainedRow, held: RetainedRow | undefined): RetainedRow { if (!held) { return item } - const { itemId: _itemId, body: _body, ...attribution } = held - return { ...item, ...attribution } + const { itemId: _itemId, body: heldBody, ...attribution } = held + return { ...item, ...attribution, body: withRecordedSender(item.body, heldBody) } } /** Provider turn id → the item id of its turn record: the turn's own, or the command turn that diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index c79619e7cb23..54479e36d5da 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -2,11 +2,7 @@ import { OrchestrationStructuredMailboxPointerDelivery } from './orchestration/structured-mailbox-pointer-delivery' import { createStructuredMailboxPointerHost } from './orchestration/structured-mailbox-pointer-host' import { localOrchestrationCliCommand } from './orchestration/cli-command' -import { readAgentSessionRecordStore } from './orchestration/structured-session-lineage' -import { - orchestrationSenderName, - type TerminalSenderNaming -} from './orchestration/orchestration-sender-name' +import { RuntimeOrchestrationSenderNames } from './runtime-orchestration-sender-names' import { isStructuredWorkerHandle } from './structured-worker-identity' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { OrcaRuntimeWithRuntimeId } from './orca-runtime-runtime-id' @@ -260,29 +256,18 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId resolveStructuredTarget: (mailboxHandle) => this.resolveStructuredMailboxTarget(mailboxHandle), getCliCommand: localOrchestrationCliCommand, - senderName: (party) => - orchestrationSenderName(party, { - db: this._orchestrationDb, - records: readAgentSessionRecordStore(), - terminal: (handle) => this.terminalSenderNaming(handle) - }), + senderName: (party) => this.orchestrationSenderNames.nameOf(party), host: createStructuredMailboxPointerHost() }) - protected terminalSenderNaming(handle: string): TerminalSenderNaming | null { - const record = this.handles.get(handle) - if (!record) { - return null - } - const pty = record.ptyId ? this.ptysById.get(record.ptyId) : undefined - const tab = this.getWorkspaceSessionForWorktree(record.worktreeId)?.tabsByWorktree?.[ - record.worktreeId - ]?.find((candidate) => candidate.id === record.tabId) - return { - customTitle: tab?.customTitle ?? null, - agent: pty?.launchAgent ?? pty?.foregroundAgent ?? null - } - } + /** What Orca calls an agent that sends through it; public for every such send site. */ + readonly orchestrationSenderNames = new RuntimeOrchestrationSenderNames({ + getDb: () => this._orchestrationDb, + getHandleRecord: (handle) => this.handles.get(handle), + getPtyAgents: (ptyId) => this.ptysById.get(ptyId), + getTerminalPaneKey: (handle) => this.getTerminalPaneKey(handle), + getWorkspaceSession: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId) + }) protected readonly orchestrationMailboxNotifications = new OrchestrationMailboxNotificationCoordinator({ diff --git a/src/main/runtime/orchestration/orchestration-party-location.test.ts b/src/main/runtime/orchestration/orchestration-party-location.test.ts new file mode 100644 index 000000000000..85f0d0b7f534 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-party-location.test.ts @@ -0,0 +1,120 @@ +// Where a message's sender opens: the host's read of a party, over the records production keeps. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' +import { locateOrchestrationParty } from './orchestration-party-location' +import type { AgentSessionRecordReader } from './structured-session-lineage' + +const ROOT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +const LIVE = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' +const PANE = 'tab_worker:leaf' + +let db: OrchestrationDb + +beforeEach(() => { + db = new OrchestrationDb(':memory:') +}) + +afterEach(() => { + db.close() +}) + +function record(sessionId: string, overrides: Partial = {}) { + const base = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) + return { ...base, location: { ...base.location, workspaceId: 'wt-chat' }, ...overrides } +} + +/** A chat `/clear` continued in LIVE: its root id still names it. */ +function clearedChat(): AgentSessionRecordReader { + const records = new Map([ + [ + ROOT, + record(ROOT, { + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: LIVE, + operationId: 'op', + callerKey: 'k', + phase: 'committed' + } + }) + ], + [LIVE, record(LIVE)] + ]) + return { getRecord: (id) => records.get(id) ?? null, listRecords: () => [...records.values()] } +} + +/** This run issued `term_now` for PANE; any other handle is from an earlier run. */ +function locate( + address: string, + deps: Partial[1]> = {}, + messageIds?: readonly string[] +) { + return locateOrchestrationParty( + address, + { + db, + records: null, + terminalPaneKey: (handle) => (handle === 'term_now' ? PANE : null), + terminalHandleForPaneKey: (paneKey) => (paneKey === PANE ? 'term_now' : null), + ...deps + }, + messageIds + ) +} + +describe('where a sender opens', () => { + it("opens a chat at its `/clear` lineage's live session, in that session's worktree", () => { + expect(locate(`orca_session_id:${ROOT}`, { records: clearedChat() })).toEqual({ + location: { kind: 'chat', sessionId: LIVE, worktreeId: 'wt-chat' } + }) + expect(locate(`orca_session_id:${ROOT}`)).toEqual({ location: null, lost: 'chat' }) + }) + + it('opens a terminal this run issued at its handle', () => { + expect(locate('term_now')).toEqual({ location: { kind: 'terminal', handle: 'term_now' } }) + }) + + it('finds a terminal from an earlier run through the pane its mail was sent from', () => { + const sent = db.insertMessage({ + from: 'term_old', + to: 'term_chat', + subject: 's', + senderPaneKey: PANE + }) + const other = db.insertMessage({ + from: 'term_other', + to: 'term_chat', + subject: 's', + senderPaneKey: PANE + }) + expect(locate('term_old', {}, [sent.id])).toEqual({ + location: { kind: 'terminal', handle: 'term_now' } + }) + // Only mail the sender itself sent can place it, and without mail the old handle is lost. + expect(locate('term_old', {}, [other.id])).toEqual({ location: null, lost: 'terminal' }) + expect(locate('term_old')).toEqual({ location: null, lost: 'terminal' }) + }) + + it("opens a dispatch at its assignee's terminal, through the pane it was assigned", () => { + const run = db.createRun({ + objective: 'o', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: null + }) + const task = db.createTask({ runId: run.id, spec: 'work' }) + const dispatch = createRootDispatch(db, task.id, 'term_old', PANE) + expect(locate(`dispatch:${dispatch.id}`)).toEqual({ + location: { kind: 'terminal', handle: 'term_now' } + }) + expect(locate('dispatch:gone')).toEqual({ location: null, lost: 'terminal' }) + }) +}) diff --git a/src/main/runtime/orchestration/orchestration-party-location.ts b/src/main/runtime/orchestration/orchestration-party-location.ts index 7acc7dd2b197..ea1698f11bd8 100644 --- a/src/main/runtime/orchestration/orchestration-party-location.ts +++ b/src/main/runtime/orchestration/orchestration-party-location.ts @@ -1,6 +1,6 @@ // Where a party named by its mailbox address is now, so a chat can open a message's sender. -import type { OrchestrationPartyLocation } from '../../../shared/orchestration-caller-status' +import type { OrchestrationPartyLocationResult } from '../../../shared/orchestration-caller-status' import type { OrcaSessionId } from '../../../shared/orca-session-address' import type { OrchestrationDb } from './db' import { resolveOrchestrationParty } from './orchestration-party' @@ -9,13 +9,18 @@ import { lineageLiveSession, type AgentSessionRecordReader } from './structured- type PartyLocationDeps = { db: OrchestrationDb | null records: AgentSessionRecordReader | null + /** Non-null only for a handle this runtime issued. */ + terminalPaneKey: (handle: string) => string | null terminalHandleForPaneKey: (paneKey: string) => string | null } +/** `messageIds`: the mail the message carried from this party, whose sender pane outlives a + * handle a previous run issued. */ export function locateOrchestrationParty( address: string, - deps: PartyLocationDeps -): OrchestrationPartyLocation | null { + deps: PartyLocationDeps, + messageIds: readonly string[] = [] +): OrchestrationPartyLocationResult { if (address.startsWith('dispatch:')) { return locateDispatchAssignee(address.slice('dispatch:'.length), deps) } @@ -24,12 +29,35 @@ export function locateOrchestrationParty( party = resolveOrchestrationParty(address, deps.db) } catch { // A worker this host lost the identity of. - return null + return { location: null, lost: 'chat' } } if (party.orcaSessionId) { return locateSession(party.orcaSessionId, deps.records) } - return party.terminalHandle ? { kind: 'terminal', handle: party.terminalHandle } : null + return party.terminalHandle + ? locateTerminal(party.terminalHandle, deps, messageIds) + : { location: null, lost: 'terminal' } +} + +/** A handle is issued per run, so one from an earlier run is found again through the pane its mail + * was sent from, as mail delivery finds it. */ +function locateTerminal( + handle: string, + deps: PartyLocationDeps, + messageIds: readonly string[] +): OrchestrationPartyLocationResult { + if (deps.terminalPaneKey(handle) !== null) { + return { location: { kind: 'terminal', handle } } + } + for (const messageId of messageIds) { + const message = deps.db?.getMessageById(messageId) + const paneKey = message?.from_handle === handle ? message.sender_pane_key : null + const live = paneKey ? deps.terminalHandleForPaneKey(paneKey) : null + if (live) { + return { location: { kind: 'terminal', handle: live } } + } + } + return { location: null, lost: 'terminal' } } /** As mail to the dispatch is routed (mailbox-delivery-target.ts): its assignee's pane, else @@ -37,7 +65,7 @@ export function locateOrchestrationParty( function locateDispatchAssignee( dispatchId: string, deps: PartyLocationDeps -): OrchestrationPartyLocation | null { +): OrchestrationPartyLocationResult { const dispatch = deps.db?.getDispatchContextById(dispatchId) if (dispatch?.assignee_orca_session_id) { return locateSession(dispatch.assignee_orca_session_id, deps.records) @@ -48,15 +76,23 @@ function locateDispatchAssignee( (paneKey ? deps.terminalHandleForPaneKey(paneKey) : null) ?? dispatch?.assignee_handle ?? remote?.terminal_handle - return handle && !handle.startsWith('dispatch:') ? locateOrchestrationParty(handle, deps) : null + return handle && !handle.startsWith('dispatch:') + ? locateOrchestrationParty(handle, deps) + : { location: null, lost: 'terminal' } } function locateSession( orcaSessionId: OrcaSessionId, records: AgentSessionRecordReader | null -): OrchestrationPartyLocation | null { +): OrchestrationPartyLocationResult { const record = records ? lineageLiveSession(records, orcaSessionId) : null return record - ? { kind: 'chat', sessionId: record.sessionId, worktreeId: record.location.workspaceId } - : null + ? { + location: { + kind: 'chat', + sessionId: record.sessionId, + worktreeId: record.location.workspaceId + } + } + : { location: null, lost: 'chat' } } diff --git a/src/main/runtime/orchestration/orchestration-sender-location.test.ts b/src/main/runtime/orchestration/orchestration-sender-location.test.ts deleted file mode 100644 index 312bd3916ee3..000000000000 --- a/src/main/runtime/orchestration/orchestration-sender-location.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -// What a chat names a message's sender, and where it opens it: the host's two reads of a party. - -import { describe, expect, it } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../../shared/agent-session-record.test-fixture' -import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' -import type { OrchestrationDb } from './db' -import type { DispatchContextRow, TaskRow } from './types' -import { orchestrationSenderName } from './orchestration-sender-name' -import { locateOrchestrationParty } from './orchestration-party-location' -import type { AgentSessionRecordReader } from './structured-session-lineage' - -const ROOT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') -const LIVE = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' - -function record(sessionId: string, overrides: Partial = {}) { - const base = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) - return { ...base, location: { ...base.location, workspaceId: 'wt-chat' }, ...overrides } -} - -/** A chat `/clear` continued in LIVE: its root id still names it. */ -function clearedChat(liveName?: string): AgentSessionRecordReader { - const records = new Map([ - [ - ROOT, - record(ROOT, { - conversationCommand: { - command: 'clear', - state: 'completed', - replacementSessionId: LIVE, - operationId: 'op', - callerKey: 'k', - phase: 'committed' - } - }) - ], - [LIVE, record(LIVE, liveName ? { conversationName: liveName } : {})] - ]) - return { getRecord: (id) => records.get(id) ?? null, listRecords: () => [...records.values()] } -} - -/** Only the dispatch lookups the reads make. */ -function dispatchDb(dispatch?: Partial, task?: Partial) { - const db = { - getDispatchContextById: () => dispatch, - getTask: () => task, - getRemoteDispatchAttachment: () => undefined - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reads under test call only these three lookups, and read only the fields set here. - return db as unknown as OrchestrationDb -} - -const chatParty = { address: `orca_session_id:${ROOT}`, terminalHandle: null, orcaSessionId: ROOT } -const terminalParty = { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null } -const dispatchParty = { address: 'dispatch:d1', terminalHandle: 'dispatch:d1', orcaSessionId: null } - -describe("a sender's name, from names Orca controls", () => { - const name = ( - party: Parameters[0], - deps: Partial[1]> = {} - ) => orchestrationSenderName(party, { db: null, records: null, terminal: () => null, ...deps }) - - it("names a chat by its live session's conversation name, else its agent's chat label", () => { - expect(name(chatParty, { records: clearedChat('Fix the build') })).toBe('Fix the build') - expect(name(chatParty, { records: clearedChat() })).toBe('Claude Chat') - expect(name(chatParty)).toBeNull() - }) - - it('names a terminal by the title the person gave its tab, else its agent', () => { - expect( - name(terminalParty, { terminal: () => ({ customTitle: ' Lint ', agent: 'codex' }) }) - ).toBe('Lint') - expect(name(terminalParty, { terminal: () => ({ customTitle: null, agent: 'codex' }) })).toBe( - 'Codex' - ) - expect(name(terminalParty, { terminal: () => ({ customTitle: null, agent: null }) })).toBeNull() - }) - - it('names a federated sender by the task its dispatch was given', () => { - const db = dispatchDb({ task_id: 't1' }, { display_name: 'Port the parser', task_title: 'x' }) - expect(name(dispatchParty, { db })).toBe('Port the parser') - expect(name(dispatchParty, { db: dispatchDb(undefined) })).toBeNull() - }) -}) - -describe('where a sender opens', () => { - const locate = (address: string, deps: Partial[1]>) => - locateOrchestrationParty(address, { - db: null, - records: null, - terminalHandleForPaneKey: () => null, - ...deps - }) - - it("opens a chat at its `/clear` lineage's live session, in that session's worktree", () => { - expect(locate(chatParty.address, { records: clearedChat() })).toEqual({ - kind: 'chat', - sessionId: LIVE, - worktreeId: 'wt-chat' - }) - expect(locate(chatParty.address, {})).toBeNull() - }) - - it('opens a terminal at its handle', () => { - expect(locate('term_a', {})).toEqual({ kind: 'terminal', handle: 'term_a' }) - }) - - it("opens a dispatch at its assignee: a chat by its session, a terminal by its pane's handle now", () => { - const chatAssignee = dispatchDb({ assignee_orca_session_id: ROOT }) - expect(locate('dispatch:d1', { db: chatAssignee, records: clearedChat() })).toMatchObject({ - kind: 'chat', - sessionId: LIVE - }) - const terminalAssignee = dispatchDb({ - assignee_pane_key: 'tab:leaf', - assignee_handle: 'term_old' - }) - expect( - locate('dispatch:d1', { - db: terminalAssignee, - terminalHandleForPaneKey: (paneKey) => (paneKey === 'tab:leaf' ? 'term_now' : null) - }) - ).toEqual({ kind: 'terminal', handle: 'term_now' }) - expect(locate('dispatch:d1', { db: terminalAssignee })).toEqual({ - kind: 'terminal', - handle: 'term_old' - }) - expect(locate('dispatch:d1', { db: dispatchDb(undefined) })).toBeNull() - }) -}) diff --git a/src/main/runtime/orchestration/orchestration-sender-name.ts b/src/main/runtime/orchestration/orchestration-sender-name.ts index eb51251dc66f..5310ce138f0f 100644 --- a/src/main/runtime/orchestration/orchestration-sender-name.ts +++ b/src/main/runtime/orchestration/orchestration-sender-name.ts @@ -1,48 +1,75 @@ -// What a chat shows a message's sender as: only names Orca controls, never a title the agent -// set on its own terminal (it could call itself "You"). +// What a chat shows a message's sender as: names from Orca's own records, most specific first, and +// never a title the agent paints on its own terminal. import type { AgentMessageSender } from '../../../shared/agent-session-message-source' import { defaultAgentChatLabel } from '../../../shared/agent-session-chat-label' import type { AgentType } from '../../../shared/agent-status-types' import { formatAgentTypeLabel } from '../../../shared/agent-type-label' +import type { Tab } from '../../../shared/tab-types' import type { OrchestrationDb } from './db' import { lineageLiveSession, type AgentSessionRecordReader } from './structured-session-lineage' export type TerminalSenderNaming = { - /** The title the person gave the tab, if any. */ + /** The tab's stored title: a rename, by the person or through the CLI. Never its live title. */ customTitle: string | null agent: AgentType | null + /** Its pane, which an active dispatch still names after the handle was reissued. */ + paneKey: string | null } -export function orchestrationSenderName( - party: AgentMessageSender['party'], - deps: { - db: OrchestrationDb | null - records: AgentSessionRecordReader | null - terminal: (handle: string) => TerminalSenderNaming | null - } -): string | null { - // A federated sender is its dispatch here: named by the task it was given. - const dispatchId = party.address.startsWith('dispatch:') - ? party.address.slice('dispatch:'.length) - : null - if (dispatchId) { - const dispatch = deps.db?.getDispatchContextById(dispatchId) - const task = dispatch ? deps.db?.getTask(dispatch.task_id) : undefined - return task?.display_name || task?.task_title || null +export type SenderNamingSources = { + db: OrchestrationDb | null + records: AgentSessionRecordReader | null + /** The chat's tab as this host mirrors the workspace session. */ + chatTab: (worktreeId: string, sessionId: string) => Pick | null + terminal: (handle: string) => TerminalSenderNaming | null +} + +type Party = AgentMessageSender['party'] + +export function orchestrationSenderName(party: Party, sources: SenderNamingSources): string | null { + const task = dispatchTaskName(party, sources) + if (task) { + return task } if (party.orcaSessionId) { - const record = deps.records ? lineageLiveSession(deps.records, party.orcaSessionId) : null + const record = sources.records ? lineageLiveSession(sources.records, party.orcaSessionId) : null if (record) { - return record.conversationName ?? defaultAgentChatLabel(record.provider) + const tab = sources.chatTab(record.location.workspaceId, record.sessionId) + return tab?.customLabel?.trim() || tab?.label.trim() || defaultAgentChatLabel(record.provider) } } - if (party.terminalHandle) { - const terminal = deps.terminal(party.terminalHandle) - if (terminal) { - const agentLabel = terminal.agent ? formatAgentTypeLabel(terminal.agent) : null - return terminal.customTitle?.trim() || agentLabel - } + const terminal = isDispatchAddress(party.address) + ? null + : party.terminalHandle + ? sources.terminal(party.terminalHandle) + : null + if (terminal) { + const agentLabel = terminal.agent ? formatAgentTypeLabel(terminal.agent) : null + return terminal.customTitle?.trim() || agentLabel } return null } + +/** The task its dispatch was given: the federated `dispatch:` address, or a local worker's + * active dispatch. */ +function dispatchTaskName(party: Party, sources: SenderNamingSources): string | null { + const db = sources.db + if (!db) { + return null + } + const dispatch = isDispatchAddress(party.address) + ? db.getDispatchContextById(party.address.slice('dispatch:'.length)) + : party.terminalHandle + ? db.getActiveDispatchForTerminal( + party.terminalHandle, + sources.terminal(party.terminalHandle)?.paneKey ?? undefined + ) + : undefined + const task = dispatch ? db.getTask(dispatch.task_id) : undefined + return task?.display_name || task?.task_title || null +} + +function isDispatchAddress(address: string): boolean { + return address.startsWith('dispatch:') +} diff --git a/src/main/runtime/orchestration/structured-mail-source.ts b/src/main/runtime/orchestration/structured-mail-source.ts index 28d6f6142df0..a0c402c759fc 100644 --- a/src/main/runtime/orchestration/structured-mail-source.ts +++ b/src/main/runtime/orchestration/structured-mail-source.ts @@ -15,9 +15,20 @@ import { resolveOrchestrationParty } from './orchestration-party' export type MailSourceMessage = Pick -/** A name Orca controls for a party (never an agent-set title); null when it has none. */ +/** A name from Orca's records for a party (never an agent-painted title); null when it has none. */ export type SenderNameResolver = (party: AgentMessageSender['party']) => string | null +/** The one way a sender is recorded on a message: its party as orchestration resolves the + * address, and a bounded snapshot of its name for when it is gone. */ +export function agentMessageSender( + address: string, + db: OrchestrationDb | null, + senderName: SenderNameResolver +): AgentMessageSender { + const party = senderParty(address, db) + return { party, name: snapshotName(senderName, party) } +} + export function structuredMailSource(input: { db: OrchestrationDb | null mailboxHandle: string @@ -28,8 +39,7 @@ export function structuredMailSource(input: { const senders = new Map() for (const { from_handle: address } of input.batch) { if (!senders.has(address)) { - const party = senderParty(address, input.db) - senders.set(address, { party, name: snapshotName(input.senderName, party) }) + senders.set(address, agentMessageSender(address, input.db, input.senderName)) } } return { diff --git a/src/main/runtime/rpc/methods/orchestration/caller-show.ts b/src/main/runtime/rpc/methods/orchestration/caller-show.ts index f206ed9d1c07..e7d801f50be0 100644 --- a/src/main/runtime/rpc/methods/orchestration/caller-show.ts +++ b/src/main/runtime/rpc/methods/orchestration/caller-show.ts @@ -50,12 +50,16 @@ export const ORCHESTRATION_CALLER_METHODS = [ name: 'orchestration.partyLocation', params: PartyLocationParams, // Why host-side: a chat sender's live session follows its `/clear` lineage on this host. - handler: (params, { runtime }): OrchestrationPartyLocationResult => ({ - location: locateOrchestrationParty(params.address, { - db: runtime.getOrchestrationDb(), - records: readAgentSessionRecordStore(), - terminalHandleForPaneKey: (paneKey) => runtime.getTerminalHandleForPaneKey(paneKey) - }) - }) + handler: (params, { runtime }): OrchestrationPartyLocationResult => + locateOrchestrationParty( + params.address, + { + db: runtime.getOrchestrationDb(), + records: readAgentSessionRecordStore(), + terminalPaneKey: (handle) => runtime.getTerminalPaneKey(handle), + terminalHandleForPaneKey: (paneKey) => runtime.getTerminalHandleForPaneKey(paneKey) + }, + params.messageIds + ) }) ] diff --git a/src/main/runtime/rpc/orchestration-party-addressing.test.ts b/src/main/runtime/rpc/orchestration-party-addressing.test.ts index a6ffd3ea5ea5..7c4fe79a33de 100644 --- a/src/main/runtime/rpc/orchestration-party-addressing.test.ts +++ b/src/main/runtime/rpc/orchestration-party-addressing.test.ts @@ -235,12 +235,33 @@ describe('every target param resolves both spellings of a party to one canonical expect( await as(undefined, 'orchestration.partyLocation', { address: `dispatch:${dispatchId}` }) ).toEqual({ location: { kind: 'chat', sessionId: SESSION_Y, worktreeId: WORKSPACE_X } }) - expect(await as(undefined, 'orchestration.partyLocation', { address: 'term_peer' })).toEqual({ - location: { kind: 'terminal', handle: 'term_peer' } + expect(await as(undefined, 'orchestration.partyLocation', { address: WORKER_HANDLE })).toEqual({ + location: { kind: 'terminal', handle: WORKER_HANDLE } }) expect( await as(undefined, 'orchestration.partyLocation', { address: 'dispatch:gone' }) - ).toEqual({ location: null }) + ).toEqual({ location: null, lost: 'terminal' }) + }) + + it("partyLocation: a terminal handle from an earlier run opens as its pane's live terminal, by the mail it sent", async () => { + vi.spyOn(h.runtime, 'getTerminalHandleForPaneKey').mockImplementation((paneKey) => + paneKey === WORKER_PANE ? WORKER_HANDLE : null + ) + const mail = h.db.insertMessage({ + from: 'term_previous_run', + to: ADDRESS_X, + subject: 's', + senderPaneKey: WORKER_PANE + }) + expect( + await as(undefined, 'orchestration.partyLocation', { + address: 'term_previous_run', + messageIds: [mail.id] + }) + ).toEqual({ location: { kind: 'terminal', handle: WORKER_HANDLE } }) + expect( + await as(undefined, 'orchestration.partyLocation', { address: 'term_previous_run' }) + ).toEqual({ location: null, lost: 'terminal' }) }) }) diff --git a/src/main/runtime/runtime-orchestration-sender-names.test.ts b/src/main/runtime/runtime-orchestration-sender-names.test.ts new file mode 100644 index 000000000000..60cb6059eee9 --- /dev/null +++ b/src/main/runtime/runtime-orchestration-sender-names.test.ts @@ -0,0 +1,119 @@ +// A sender's name snapshot, read from the sources production fills: dispatch tasks in the +// orchestration database, the chat tabs and terminal tabs of the workspace session this host +// mirrors, and the terminal's own pty. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { OrchestrationDb } from './orchestration/db' +import { createRootDispatch } from './orchestration/db/root-dispatch-test-fixture' +import { RuntimeOrchestrationSenderNames } from './runtime-orchestration-sender-names' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +const CHAT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +const WORKTREE = 'repo_1::/work/tree' + +type Session = NonNullable< + ReturnType< + ConstructorParameters[0]['getWorkspaceSession'] + > +> + +let db: OrchestrationDb +let session: Session +let records: Map + +function names() { + return new RuntimeOrchestrationSenderNames({ + getDb: () => db, + getHandleRecord: (handle) => + handle === 'term_worker' || handle === 'term_plain' + ? { worktreeId: WORKTREE, tabId: `tab_${handle}`, ptyId: `pty_${handle}` } + : undefined, + getPtyAgents: () => ({ launchAgent: 'codex' }), + getTerminalPaneKey: (handle) => `tab_${handle}:leaf`, + getWorkspaceSession: (worktreeId) => (worktreeId === WORKTREE ? session : undefined) + }) +} + +function chatTab(customLabel: string | null, label: string) { + return { contentType: 'agent-session' as const, entityId: CHAT, customLabel, label } +} + +beforeEach(() => { + db = new OrchestrationDb(':memory:') + session = { unifiedTabs: {}, tabsByWorktree: {} } + const base = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: CHAT })) + records = new Map([[CHAT, { ...base, location: { ...base.location, workspaceId: WORKTREE } }]]) + hostRef.current = { + deps: { + store: { getRecord: (id: string) => records.get(id) ?? null, listRecords: () => [] } + } + } +}) + +afterEach(() => { + db.close() +}) + +const chatParty = { address: `orca_session_id:${CHAT}`, terminalHandle: null, orcaSessionId: CHAT } +const terminalParty = (handle: string) => ({ + address: handle, + terminalHandle: handle, + orcaSessionId: null +}) + +describe("a sender's name, from what Orca shows for it", () => { + it("names a local worker by its active dispatch's task, before its tab or agent", () => { + const run = db.createRun({ + objective: 'o', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: null + }) + const task = db.createTask({ + runId: run.id, + spec: 'Port the parser', + displayName: 'Parser port' + }) + createRootDispatch(db, task.id, 'term_worker') + session.tabsByWorktree = { [WORKTREE]: [{ id: 'tab_term_worker', customTitle: 'Build tab' }] } + expect(names().nameOf(terminalParty('term_worker'))).toBe('Parser port') + }) + + it("names a chat by the label its tab shows, the person's rename first", () => { + session.unifiedTabs = { [WORKTREE]: [chatTab(null, 'Fix the login flow')] } + expect(names().nameOf(chatParty)).toBe('Fix the login flow') + session.unifiedTabs = { [WORKTREE]: [chatTab('Auth chat', 'Fix the login flow')] } + expect(names().nameOf(chatParty)).toBe('Auth chat') + }) + + it("names a chat with no tab by its agent's chat label", () => { + expect(names().nameOf(chatParty)).toBe('Claude Chat') + }) + + it("names a terminal by its tab's stored title, else its agent", () => { + expect(names().nameOf(terminalParty('term_plain'))).toBe('Codex') + session.tabsByWorktree = { [WORKTREE]: [{ id: 'tab_term_plain', customTitle: ' Lint ' }] } + expect(names().nameOf(terminalParty('term_plain'))).toBe('Lint') + }) + + it('names a party nothing records as nothing', () => { + expect(names().nameOf(terminalParty('term_unknown'))).toBeNull() + }) + + it('builds the one sender a message records: its party and the name it has now', () => { + session.unifiedTabs = { [WORKTREE]: [chatTab(null, 'Fix the login flow')] } + expect(names().sender(`orca_session_id:${CHAT}`)).toEqual({ + party: { address: `orca_session_id:${CHAT}`, terminalHandle: null, orcaSessionId: CHAT }, + name: 'Fix the login flow' + }) + }) +}) diff --git a/src/main/runtime/runtime-orchestration-sender-names.ts b/src/main/runtime/runtime-orchestration-sender-names.ts new file mode 100644 index 000000000000..8faba932ed26 --- /dev/null +++ b/src/main/runtime/runtime-orchestration-sender-names.ts @@ -0,0 +1,73 @@ +// The runtime's answer to "what is this agent called", from its own records, for any send that +// speaks for another agent: the mail lane today, a dispatched task next. + +import type { AgentMessageSender } from '../../shared/agent-session-message-source' +import type { AgentType } from '../../shared/agent-status-types' +import type { Tab } from '../../shared/tab-types' +import type { TerminalTab } from '../../shared/terminal-tab-types' +import type { OrchestrationDb } from './orchestration/db' +import { + orchestrationSenderName, + type TerminalSenderNaming +} from './orchestration/orchestration-sender-name' +import { agentMessageSender } from './orchestration/structured-mail-source' +import { readAgentSessionRecordStore } from './orchestration/structured-session-lineage' + +type RuntimeSenderNameDeps = { + getDb: () => OrchestrationDb | null + getHandleRecord: ( + handle: string + ) => { worktreeId: string; tabId: string; ptyId: string | null } | undefined + getPtyAgents: ( + ptyId: string + ) => { launchAgent?: AgentType | null; foregroundAgent?: AgentType | null } | undefined + getTerminalPaneKey: (handle: string) => string | null + /** The workspace session this host mirrors, of which only the tabs are read. */ + getWorkspaceSession: (worktreeId: string) => MirroredTabs | null | undefined +} + +type MirroredTabs = { + unifiedTabs?: Readonly< + Record[]> + > + tabsByWorktree?: Readonly[]>> +} + +export class RuntimeOrchestrationSenderNames { + constructor(private readonly deps: RuntimeSenderNameDeps) {} + + /** A sender recorded on a message, named now. */ + sender(address: string): AgentMessageSender { + return agentMessageSender(address, this.deps.getDb(), (party) => this.nameOf(party)) + } + + nameOf(party: AgentMessageSender['party']): string | null { + return orchestrationSenderName(party, { + db: this.deps.getDb(), + records: readAgentSessionRecordStore(), + chatTab: (worktreeId, sessionId) => + this.deps + .getWorkspaceSession(worktreeId) + ?.unifiedTabs?.[worktreeId]?.find( + (tab) => tab.contentType === 'agent-session' && tab.entityId === sessionId + ) ?? null, + terminal: (handle) => this.terminalNaming(handle) + }) + } + + private terminalNaming(handle: string): TerminalSenderNaming | null { + const record = this.deps.getHandleRecord(handle) + if (!record) { + return null + } + const pty = record.ptyId ? this.deps.getPtyAgents(record.ptyId) : undefined + const tab = this.deps + .getWorkspaceSession(record.worktreeId) + ?.tabsByWorktree?.[record.worktreeId]?.find((candidate) => candidate.id === record.tabId) + return { + customTitle: tab?.customTitle ?? null, + agent: pty?.launchAgent ?? pty?.foregroundAgent ?? null, + paneKey: this.deps.getTerminalPaneKey(handle) + } + } +} diff --git a/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts b/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts index 68cf83bb0ebc..2326d6248311 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts @@ -83,8 +83,8 @@ describe("a busy chat's orchestration pointer waits in its queue", () => { senders: [ { party: { address: 'term_worker', terminalHandle: 'term_worker', orcaSessionId: null }, - // No tab this runtime knows of names it, and an agent-set title never does. - name: null + // Named by the task its dispatch was given, through the real runtime's naming. + name: 'build it' } ], orchestration: { diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx index c2c684925f5f..8f6e16df9a52 100644 --- a/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx @@ -7,6 +7,7 @@ import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { MessageRow } from './NativeChatMessageRow' import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard' import { TooltipProvider } from '@/components/ui/tooltip' +import { queuedCardSenderLine } from './native-chat-agent-message-sender-label' const openAgentMessageSender = vi.hoisted(() => vi.fn(async () => {})) vi.mock('@/lib/open-agent-message-sender', () => ({ openAgentMessageSender })) @@ -61,10 +62,22 @@ describe("another agent's message in the transcript", () => { expect(screen.getByText('You have 1 orchestration message.')).toBeInTheDocument() }) - it("opens the sender from its name, on the chat's own host", () => { - renderUserMessage(from([{ address: CODER.address, name: 'Coder' }])) + it("opens the sender from its name, on the chat's own host, with the message it sent", () => { + const source = from([{ address: CODER.address, name: 'Coder' }]) + renderUserMessage(source) fireEvent.click(screen.getByRole('button', { name: 'Coder' })) - expect(openAgentMessageSender).toHaveBeenCalledWith(CODER, 'wt-chat') + expect(openAgentMessageSender).toHaveBeenCalledWith(source, source.senders[0], 'wt-chat') + }) + + it('separates the names as the queued card does, and keeps two same-named senders two', () => { + const { container } = renderUserMessage( + from([ + { address: 'term_a', name: 'Codex' }, + { address: 'term_b', name: 'Codex' } + ]) + ) + expect(screen.getAllByRole('button', { name: 'Codex' })).toHaveLength(2) + expect(container.textContent).toContain('Message fromCodex,Codex') }) it('names each sender, an unnamed one as an agent, and counts the rest', () => { @@ -124,4 +137,14 @@ describe("another agent's queued card", () => { expect(screen.getByText('From Coder, an agent')).toBeInTheDocument() expect(screen.queryByRole('button', { name: /Coder/ })).not.toBeInTheDocument() }) + + it('counts in "+N" only the senders it does not name, same-named ones included', () => { + expect( + queuedCardSenderLine( + from( + ['term_a', 'term_b', 'term_c', 'term_d'].map((address) => ({ address, name: 'Codex' })) + ) + ) + ).toBe('From Codex, Codex, Codex +1') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx index f4566af261f4..caa49102e4dd 100644 --- a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx @@ -1,3 +1,4 @@ +import { Fragment } from 'react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { openAgentMessageSender } from '@/lib/open-agent-message-sender' @@ -21,27 +22,33 @@ export function NativeChatAgentMessageSenders({
{translate('components.native-chat.agentMessage.messageFrom', 'Message from')} {shown.length === 0 ? ( - + {translate('components.native-chat.agentMessage.unnamedSender', 'an agent')} ) : null} - {shown.map((sender) => - chatWorktreeId ? ( - - ) : ( - - {agentMessageSenderLabel(sender)} - - ) - )} + {shown.map((sender, index) => ( + // Fragment keys: one sender's name and the separator after it. + + {chatWorktreeId ? ( + + ) : ( + {agentMessageSenderLabel(sender)} + )} + {/* Pulled back over the name's padding, so it reads "A, B" as the queued card does. */} + {index < shown.length - 1 ? ( + + , + + ) : null} + + ))} {more > 0 ? +{more} : null}
) 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 ebf50ef3e2f5..a2e44282382a 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 @@ -16,8 +16,9 @@ export function agentMessageSenderLabel(sender: AgentMessageSender): string { /** A queued card's plain "From " line; the card's own controls own its clicks. */ export function queuedCardSenderLine(from: AgentMessageSource): string { const { shown, more } = agentMessageSendersShown(from) - const names = [...new Set(shown.map(agentMessageSenderLabel))] - const listed = names.length > 0 ? names.join(', ') : unnamedSenderLabel() + // One name per sender, repeats included: two senders that share a name are still two. + const listed = + shown.length > 0 ? shown.map(agentMessageSenderLabel).join(', ') : unnamedSenderLabel() return translate('components.native-chat.queuedMessages.from', 'From {{names}}', { names: more > 0 ? `${listed} +${more}` : listed }) diff --git a/src/renderer/src/components/native-chat/native-chat-rewind-eligibility.ts b/src/renderer/src/components/native-chat/native-chat-rewind-eligibility.ts index b9bf366fcd0e..a0bf44185bec 100644 --- a/src/renderer/src/components/native-chat/native-chat-rewind-eligibility.ts +++ b/src/renderer/src/components/native-chat/native-chat-rewind-eligibility.ts @@ -11,7 +11,8 @@ export function nativeChatRewindOffered(support: AgentSessionRewindSupport | und /** A sent prompt that opened its own turn, outside any subagent's section. Codex rewinds whole * turns, so a steer would also discard its turn's opener; unsent, queued, command and goal rows - * have no turn to go back to. An image with no local file could not go back to the composer. */ + * have no turn to go back to. An image with no local file could not go back to the composer, and + * another agent's message is not the person's to take back into their composer. */ export function nativeChatRowOffersRewind( message: NativeChatMessage, slot: { depth: number; turnKey: string | undefined }, @@ -25,6 +26,7 @@ export function nativeChatRowOffersRewind( message.queued !== true && message.command === undefined && message.sentAs === undefined && + message.from === undefined && message.blocks.every((block) => block.type !== 'image-ref' || Boolean(block.path)) ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx index 16a59098f778..b30730b2056d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx @@ -447,6 +447,23 @@ describe('which rows offer rewind', () => { ['a queued row', message({ queued: true }), opens, false], ['a /compact row', message({ command: { name: 'compact' } }), opens, false], ['a goal', message({ sentAs: 'goal' }), opens, false], + [ + "another agent's message, which is not the person's to take back", + message({ + from: { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: null + } + }), + opens, + false + ], [ 'a prompt with a URL image, which could not go back to the composer', message({ blocks: [{ type: 'image-ref', url: 'https://example.com/shot.png' }] }), diff --git a/src/renderer/src/lib/open-agent-message-sender.test.ts b/src/renderer/src/lib/open-agent-message-sender.test.ts index f287c435363f..4034a5c7fcba 100644 --- a/src/renderer/src/lib/open-agent-message-sender.test.ts +++ b/src/renderer/src/lib/open-agent-message-sender.test.ts @@ -11,9 +11,11 @@ const mocks = vi.hoisted(() => ({ gone: vi.fn(), hostCannotOpen: vi.fn(), unavailable: vi.fn(), + toastError: vi.fn(), tabs: new Map([['current', {}]]) })) +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ ...(await importOriginal>()), callRuntimeRpc: mocks.callRuntimeRpc, @@ -46,6 +48,9 @@ vi.mock('@/store', () => ({ })) import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +import { RUNTIME_COMPAT_BLOCK_CODE } from '@/runtime/runtime-protocol-compat' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../shared/agent-session-write-notice-copy' import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' import { openAgentMessageSender } from './open-agent-message-sender' @@ -55,6 +60,28 @@ const chat = { address: `orca_session_id:${ROOT}`, terminalHandle: null, orcaSes const terminal = { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null } const dispatch = { address: 'dispatch:d1', terminalHandle: 'dispatch:d1', orcaSessionId: null } +/** A notice carrying mail from each party: m1 from the terminal, m2 from someone else. */ +function sourceFor(party: typeof chat | typeof terminal | typeof dispatch): AgentMessageSource { + return { + kind: 'agent', + senders: [{ party, name: 'Sender' }], + orchestration: { + message: 'mail-notice', + mailbox: 'run:r1', + dispatchId: null, + messages: [ + { messageId: 'm1', runId: 'r1', from: party.address }, + { messageId: 'm2', runId: 'r1', from: 'term_other' } + ] + } + } +} + +function open(party: typeof chat | typeof terminal | typeof dispatch) { + const source = sourceFor(party) + return openAgentMessageSender(source, source.senders[0]!, 'wt-chat') +} + function failure(code: string): RuntimeRpcCallError { return new RuntimeRpcCallError({ id: 'rpc_1', @@ -64,82 +91,118 @@ function failure(code: string): RuntimeRpcCallError { }) } +function versionBlock(): Error { + return Object.assign(new Error('update needed'), { code: RUNTIME_COMPAT_BLOCK_CODE }) +} + +const UNREACHABLE = AGENT_SESSION_WRITE_NOTICE_COPY.unreachable +const UNSUPPORTED = AGENT_SESSION_WRITE_NOTICE_COPY.unsupported + beforeEach(() => { vi.clearAllMocks() mocks.tabs.set('current', {}) }) describe("opening a message's sender", () => { - it('focuses a terminal sender through the terminal-handle path, with no lookup', async () => { + it("asks the chat's host where a terminal is, with the mail it sent, and focuses what it finds", async () => { + mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'terminal', handle: 'term_now' } }) mocks.focusRenderer.mockReturnValue(true) - await openAgentMessageSender(terminal, 'wt-chat') - expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() - expect(mocks.focusRenderer).toHaveBeenCalledWith('term_a', 'env-host') + await open(terminal) + // The stored handle may be from an earlier run; only the host knows its live one. + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(HOST, 'orchestration.partyLocation', { + address: 'term_a', + messageIds: ['m1'] + }) + expect(mocks.focusRenderer).toHaveBeenCalledWith('term_now', 'env-host') expect(mocks.focusRuntime).not.toHaveBeenCalled() }) - it("asks the chat's host to focus a terminal this window does not show, and says so when it is gone", async () => { + it('says the pane is gone only when the terminal answer proves it', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'terminal', handle: 'term_a' } }) mocks.focusRenderer.mockReturnValue(false) - mocks.focusRuntime.mockRejectedValue(new Error('terminal_not_found')) - await openAgentMessageSender(terminal, 'wt-chat') - expect(mocks.focusRuntime).toHaveBeenCalledWith('term_a', 'env-host') - expect(mocks.paneUnavailable).toHaveBeenCalledTimes(1) + for (const code of [ + 'terminal_exited', + 'terminal_gone', + 'terminal_handle_stale', + 'terminal_not_found' + ]) { + mocks.focusRuntime.mockRejectedValueOnce(failure(code)) + await open(terminal) + } + expect(mocks.paneUnavailable).toHaveBeenCalledTimes(4) + expect(mocks.toastError).not.toHaveBeenCalled() }) - it("opens a chat sender at its live session through the open-chat flow, asking the chat's host", async () => { + it("says the host could not be reached, or needs updating, when the terminal's host does not answer", async () => { + mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'terminal', handle: 'term_a' } }) + mocks.focusRenderer.mockReturnValue(false) + mocks.focusRuntime.mockRejectedValueOnce(failure('request_timeout')) + await open(terminal) + mocks.focusRuntime.mockRejectedValueOnce(versionBlock()) + await open(terminal) + expect(mocks.toastError.mock.calls.map(([text]) => text)).toEqual([UNREACHABLE, UNSUPPORTED]) + expect(mocks.paneUnavailable).not.toHaveBeenCalled() + expect(mocks.unavailable).not.toHaveBeenCalled() + }) + + it('opens a chat sender at its live session through the open-chat flow', async () => { mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'chat', sessionId: 'live-session', worktreeId: 'wt-sender' } }) - await openAgentMessageSender(chat, 'wt-chat') - expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(HOST, 'orchestration.partyLocation', { - address: chat.address - }) + await open(chat) expect(mocks.activateChat).toHaveBeenCalledWith({ structuredSession: { workspaceId: 'wt-sender', sessionId: 'live-session' } }) }) - it("resolves a dispatch sender on the host, to its assignee's terminal", async () => { - mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'terminal', handle: 'term_w' } }) - mocks.focusRenderer.mockReturnValue(true) - await openAgentMessageSender(dispatch, 'wt-chat') - expect(mocks.focusRenderer).toHaveBeenCalledWith('term_w', 'env-host') - }) - - it('gives the existing feedback for a sender the host no longer knows', async () => { - mocks.callRuntimeRpc.mockResolvedValue({ location: null }) - await openAgentMessageSender(chat, 'wt-chat') + it('names what the host lost, not how the sender was addressed', async () => { + // A dispatch whose assignee was a chat gets the chat's words; one whose assignee was a pane, the pane's. + mocks.callRuntimeRpc.mockResolvedValueOnce({ location: null, lost: 'chat' }) + await open(dispatch) + mocks.callRuntimeRpc.mockResolvedValueOnce({ location: null, lost: 'terminal' }) + await open(dispatch) expect(mocks.gone).toHaveBeenCalledTimes(1) - await openAgentMessageSender(dispatch, 'wt-chat') expect(mocks.paneUnavailable).toHaveBeenCalledTimes(1) expect(mocks.activateChat).not.toHaveBeenCalled() }) - it('says the host could not be reached rather than that the sender is gone', async () => { - mocks.callRuntimeRpc.mockRejectedValue(new Error('socket closed')) - await openAgentMessageSender(chat, 'wt-chat') - expect(mocks.unavailable).toHaveBeenCalledTimes(1) + it('says the host could not be reached, or needs updating, when the lookup fails, for any sender', async () => { + mocks.callRuntimeRpc.mockRejectedValueOnce(new Error('socket closed')) + await open(terminal) + mocks.callRuntimeRpc.mockRejectedValueOnce(versionBlock()) + await open(chat) + expect(mocks.toastError.mock.calls.map(([text]) => text)).toEqual([UNREACHABLE, UNSUPPORTED]) + expect(mocks.unavailable).not.toHaveBeenCalled() expect(mocks.gone).not.toHaveBeenCalled() }) - it('on a host before the lookup, opens a chat this window shows under its id, else asks for an update', async () => { + it('treats a location kind a newer host answers with as needing an update', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'browser', url: 'x' } }) + await open(terminal) + expect(mocks.toastError).toHaveBeenCalledWith(UNSUPPORTED) + expect(mocks.focusRenderer).not.toHaveBeenCalled() + }) + + it('on a host before the lookup, opens what this window can, else asks for an update', async () => { mocks.callRuntimeRpc.mockRejectedValue(failure('method_not_found')) mocks.tabs.set('current', { 'wt-sender': [{ contentType: 'agent-session', entityId: ROOT }] }) - await openAgentMessageSender(chat, 'wt-chat') + await open(chat) expect(mocks.activateChat).toHaveBeenCalledWith({ structuredSession: { workspaceId: 'wt-sender', sessionId: ROOT } }) - mocks.tabs.set('current', {}) - await openAgentMessageSender(chat, 'wt-chat') - expect(mocks.hostCannotOpen).toHaveBeenCalledTimes(1) + mocks.focusRenderer.mockReturnValue(true) + await open(terminal) + expect(mocks.focusRenderer).toHaveBeenCalledWith('term_a', 'env-host') + await open(dispatch) + expect(mocks.toastError).toHaveBeenCalledWith(UNSUPPORTED) }) it('runs one open per sender at a time', async () => { let answer: (value: unknown) => void = () => {} mocks.callRuntimeRpc.mockReturnValue(new Promise((resolve) => (answer = resolve))) - const first = openAgentMessageSender(chat, 'wt-chat') - const second = openAgentMessageSender(chat, 'wt-chat') - answer({ location: null }) + const first = open(chat) + const second = open(chat) + answer({ location: null, lost: 'chat' }) await Promise.all([first, second]) expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(1) }) diff --git a/src/renderer/src/lib/open-agent-message-sender.ts b/src/renderer/src/lib/open-agent-message-sender.ts index ba76a9ea19e1..dc97dd000495 100644 --- a/src/renderer/src/lib/open-agent-message-sender.ts +++ b/src/renderer/src/lib/open-agent-message-sender.ts @@ -1,7 +1,11 @@ // Opening the agent a chat message is from: its chat through the open-chat flow, or its terminal -// through the terminal-handle link's path, with the feedback those already give when they cannot. +// through the terminal-handle link's path. Feedback is chosen by what the host found, never by how +// the sender was addressed, and uses words that already exist. -import type { AgentMessageSender } from '../../../shared/agent-session-message-source' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../shared/agent-session-write-notice-copy' import type { OrchestrationPartyLocation, OrchestrationPartyLocationResult @@ -13,6 +17,7 @@ import { RuntimeRpcCallError, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' import { focusRendererTerminalHandle, focusRuntimeTerminalHandle @@ -24,102 +29,148 @@ import { } from './activate-ai-vault-structured-session' import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' -type Party = AgentMessageSender['party'] +type Sender = AgentMessageSource['senders'][number] +type Lookup = OrchestrationPartyLocationResult | 'unsupported' | 'unreachable' + +/** The answers that prove the terminal is gone; anything else proves nothing about it. */ +const TERMINAL_GONE_CODES = new Set([ + 'terminal_exited', + 'terminal_gone', + 'terminal_handle_stale', + 'terminal_not_found' +]) const opensInFlight = new Map>() /** `chatWorktreeId`: the chat showing the message. The sender lives on that chat's host, so every * call goes to that host. */ -export function openAgentMessageSender(party: Party, chatWorktreeId: string): Promise { - const key = `${chatWorktreeId}\0${party.address}` +export function openAgentMessageSender( + source: AgentMessageSource, + sender: Sender, + chatWorktreeId: string +): Promise { + const key = `${chatWorktreeId}\0${sender.party.address}` const inFlight = opensInFlight.get(key) if (inFlight) { return inFlight } - const opening = openSender(party, chatWorktreeId).finally(() => opensInFlight.delete(key)) + const opening = openSender(source, sender, chatWorktreeId).finally(() => + opensInFlight.delete(key) + ) opensInFlight.set(key, opening) return opening } -async function openSender(party: Party, chatWorktreeId: string): Promise { +async function openSender( + source: AgentMessageSource, + { party }: Sender, + chatWorktreeId: string +): Promise { const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), chatWorktreeId) - const located = await locateSender( + // The mail it carried from this sender, whose pane outlives a handle from an earlier run. + const messageIds = (source.orchestration?.messages ?? []) + .filter((message) => message.from === party.address) + .map((message) => message.messageId) + const found = await lookUpSender( party, + messageIds, getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }) ) - if (located === 'host-cannot-open') { - structuredSessionOpenFeedback.hostCannotOpen() + if (found === 'unsupported' || found === 'unreachable') { + showLookupFailure(found) return } - if (located === 'unreachable') { - structuredSessionOpenFeedback.unavailable() - return - } - if (located === null) { - if (party.orcaSessionId) { + const { location } = found + if (!location) { + if (found.lost === 'chat') { structuredSessionOpenFeedback.gone() } else { showAgentPaneUnavailable() } return } - if (located.kind === 'chat') { + if (location.kind === 'chat') { await activateAiVaultStructuredSession({ - structuredSession: { workspaceId: located.worktreeId, sessionId: located.sessionId } + structuredSession: { workspaceId: location.worktreeId, sessionId: location.sessionId } }) return } - if (focusRendererTerminalHandle(located.handle, environmentId)) { + if (location.kind !== 'terminal') { + // A kind a newer host answers with. + showLookupFailure('unsupported') + return + } + await focusTerminal(location.handle, environmentId) +} + +async function focusTerminal(handle: string, environmentId: string | null): Promise { + if (focusRendererTerminalHandle(handle, environmentId)) { return } try { - await focusRuntimeTerminalHandle(located.handle, environmentId) - } catch { - showAgentPaneUnavailable() + await focusRuntimeTerminalHandle(handle, environmentId) + } catch (error) { + if (error instanceof RuntimeRpcCallError && TERMINAL_GONE_CODES.has(error.code)) { + showAgentPaneUnavailable() + } else { + showLookupFailure(isRuntimeCompatBlockError(error) ? 'unsupported' : 'unreachable') + } } } -async function locateSender( - party: Party, +async function lookUpSender( + party: Sender['party'], + messageIds: readonly string[], host: RuntimeClientTarget -): Promise { - // A terminal is its own handle: no lookup, as the terminal-handle link resolves one. - if (party.orcaSessionId === null && isPlainTerminalHandle(party.terminalHandle)) { - return { kind: 'terminal', handle: party.terminalHandle } - } +): Promise { try { - const result = await callRuntimeRpc( + return await callRuntimeRpc( host, 'orchestration.partyLocation', - { address: party.address } + { address: party.address, ...(messageIds.length > 0 ? { messageIds } : {}) } ) - return result.location } catch (error) { if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { - return locateOnOlderHost(party) + return lookUpOnOlderHost(party) } - return 'unreachable' + return isRuntimeCompatBlockError(error) ? 'unsupported' : 'unreachable' } } -function isPlainTerminalHandle(handle: string | null): handle is string { - return handle !== null && !handle.startsWith('dispatch:') && !handle.startsWith('run:') -} - -/** A host that predates the lookup: a chat open here under its root id, as a chat that was - * never `/clear`ed is; otherwise only an update can open it. */ -function locateOnOlderHost(party: Party): OrchestrationPartyLocation | 'host-cannot-open' { +/** A host before the lookup: a chat open here under its root id, as one never `/clear`ed is, or a + * terminal by its handle; anything else needs that host updated. */ +function lookUpOnOlderHost(party: Sender['party']): Lookup { const sessionId = party.orcaSessionId if (sessionId) { const tabsByWorktree = useAppStore.getState().unifiedTabsByWorktree for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { if (tabs.some((tab) => tab.contentType === 'agent-session' && tab.entityId === sessionId)) { - return { kind: 'chat', sessionId, worktreeId } + return located({ kind: 'chat', sessionId, worktreeId }) } } - return 'host-cannot-open' + return 'unsupported' } - return isPlainTerminalHandle(party.terminalHandle) - ? { kind: 'terminal', handle: party.terminalHandle } - : 'host-cannot-open' + const handle = party.terminalHandle + return handle && !handle.startsWith('dispatch:') && !handle.startsWith('run:') + ? located({ kind: 'terminal', handle }) + : 'unsupported' +} + +function located(location: OrchestrationPartyLocation): OrchestrationPartyLocationResult { + return { location } +} + +/** Words for a sender of any kind: the host could not answer, or must be updated first. */ +function showLookupFailure(failure: 'unsupported' | 'unreachable'): void { + toast.error( + failure === 'unsupported' + ? translate( + 'components.native-chat.writeNotice.unsupported', + AGENT_SESSION_WRITE_NOTICE_COPY.unsupported + ) + : translate( + 'components.native-chat.writeNotice.unreachable', + AGENT_SESSION_WRITE_NOTICE_COPY.unreachable + ) + ) } diff --git a/src/shared/agent-session-message-source.test.ts b/src/shared/agent-session-message-source.test.ts index 444e44eaa5b6..c4d3cd127908 100644 --- a/src/shared/agent-session-message-source.test.ts +++ b/src/shared/agent-session-message-source.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { agentMessageSendersShown, - normalizeAgentMessageBodyFrom, readAgentMessageSource, type AgentMessageSource } from './agent-session-message-source' @@ -73,15 +72,6 @@ describe("a message's sender, read through its one reader", () => { } }) - it('normalizes a stored body in place, dropping what it cannot read', () => { - const kept: Record = { kind: 'message', from: { ...FROM, extra: 1 } } - normalizeAgentMessageBodyFrom(kept) - expect(kept.from).toEqual(FROM) - const dropped: Record = { kind: 'message', from: 'nobody' } - normalizeAgentMessageBodyFrom(dropped) - expect('from' in dropped).toBe(false) - }) - it('names at most a few senders and counts the rest', () => { const many: AgentMessageSource = { ...FROM, diff --git a/src/shared/agent-session-message-source.ts b/src/shared/agent-session-message-source.ts index 72d61e294c6d..b27270cf2c23 100644 --- a/src/shared/agent-session-message-source.ts +++ b/src/shared/agent-session-message-source.ts @@ -87,18 +87,17 @@ export function readAgentMessageSource(value: unknown): AgentMessageSource | und return parsed.success ? parsed.data : undefined } -/** A message body's `from` replaced in place by what `readAgentMessageSource` reads of it, at a - * boundary that keeps the stored object. */ -export function normalizeAgentMessageBodyFrom(body: Record): void { - if (!('from' in body)) { - return - } - const from = readAgentMessageSource(body.from) - if (from) { - body.from = from - } else { - delete body.from - } +/** A provider's copy of a user message the host recorded: the provider's body, with the sender the + * host stamped put back, since no provider ever carries it. */ +export function withRecordedSender(provider: T, recorded: object): T { + const from = 'from' in recorded ? recorded.from : undefined + return from !== undefined && isUserMessage(recorded) && isUserMessage(provider) + ? { ...provider, from } + : provider +} + +function isUserMessage(body: object): boolean { + return 'kind' in body && body.kind === 'message' && 'role' in body && body.role === 'user' } /** The senders a label names, and how many more it counts; one notice can carry many. */ diff --git a/src/shared/orchestration-caller-status.ts b/src/shared/orchestration-caller-status.ts index d49581e26150..b539763a6651 100644 --- a/src/shared/orchestration-caller-status.ts +++ b/src/shared/orchestration-caller-status.ts @@ -31,5 +31,9 @@ export type OrchestrationPartyLocation = | { kind: 'chat'; sessionId: string; worktreeId: string } | { kind: 'terminal'; handle: string } -/** `orchestration.partyLocation`: null when this host no longer knows where the party is. */ -export type OrchestrationPartyLocationResult = { location: OrchestrationPartyLocation | null } +/** `orchestration.partyLocation`: a null location is a party this host no longer finds; `lost` + * says what it was, so the click names a gone chat or a gone pane. */ +export type OrchestrationPartyLocationResult = { + location: OrchestrationPartyLocation | null + lost?: 'chat' | 'terminal' +} diff --git a/src/shared/rpc-contract/orchestration-params.ts b/src/shared/rpc-contract/orchestration-params.ts index 311d9ee30ab0..cfbecf7bf7a7 100644 --- a/src/shared/rpc-contract/orchestration-params.ts +++ b/src/shared/rpc-contract/orchestration-params.ts @@ -111,9 +111,11 @@ export const SessionAddressParams = z.object({ sessionId: requiredString('Missing sessionId') }) -/** A party's mailbox address, as a message's sender recorded it. */ +/** A party's mailbox address, as a message's sender recorded it, and the mail that message + * carried from it: a handle from an earlier run is found again through that mail's sender pane. */ export const PartyLocationParams = z.object({ - address: requiredString('Missing address') + address: requiredString('Missing address'), + messageIds: z.array(z.string().min(1).max(256)).max(50).optional() }) export const DispatchShowParams = z.object({ From fb82a9f4f2a4671675d155b64902df3e23978cc1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:30:40 -0700 Subject: [PATCH 3/6] fix(native-chat): name a worker's final report by its task; clearer open-sender failures - A worker's own worker_done settles its dispatch before the mail lane names who the report is from, so a terminal worker's completion read "Codex". A local worker is now named by the dispatch it holds, else the one it last held (handles are per run, so that is this run's). The coordinator mail rig now attests the worker's process, so its report is accepted and settles as it does in production. - Opening a sender: a version block or an unknown answer says "Update Orca to open it.", since either side may be the older one; "This needs a newer Orca on the computer running this chat" stays only for a host that predates the lookup. "Orca couldn't reach the agent." is followed by "Try again.", each sentence translated on its own. No new copy. --- .../orchestration-sender-name.ts | 38 ++++++++-------- ...runtime-orchestration-sender-names.test.ts | 43 +++++++++++++++++++ ...ctured-chat-coordinator-mail-queue.test.ts | 6 ++- ...-chat-coordinator-mail-rig.test-fixture.ts | 9 +++- .../src/lib/open-agent-message-sender.test.ts | 17 +++++--- .../src/lib/open-agent-message-sender.ts | 40 ++++++++--------- 6 files changed, 102 insertions(+), 51 deletions(-) diff --git a/src/main/runtime/orchestration/orchestration-sender-name.ts b/src/main/runtime/orchestration/orchestration-sender-name.ts index 5310ce138f0f..f38edb44d7f4 100644 --- a/src/main/runtime/orchestration/orchestration-sender-name.ts +++ b/src/main/runtime/orchestration/orchestration-sender-name.ts @@ -28,7 +28,10 @@ export type SenderNamingSources = { type Party = AgentMessageSender['party'] export function orchestrationSenderName(party: Party, sources: SenderNamingSources): string | null { - const task = dispatchTaskName(party, sources) + const federated = party.address.startsWith('dispatch:') + const terminal = + !federated && party.terminalHandle ? sources.terminal(party.terminalHandle) : null + const task = dispatchTaskName(party, federated, terminal, sources.db) if (task) { return task } @@ -39,11 +42,6 @@ export function orchestrationSenderName(party: Party, sources: SenderNamingSourc return tab?.customLabel?.trim() || tab?.label.trim() || defaultAgentChatLabel(record.provider) } } - const terminal = isDispatchAddress(party.address) - ? null - : party.terminalHandle - ? sources.terminal(party.terminalHandle) - : null if (terminal) { const agentLabel = terminal.agent ? formatAgentTypeLabel(terminal.agent) : null return terminal.customTitle?.trim() || agentLabel @@ -51,25 +49,25 @@ export function orchestrationSenderName(party: Party, sources: SenderNamingSourc return null } -/** The task its dispatch was given: the federated `dispatch:` address, or a local worker's - * active dispatch. */ -function dispatchTaskName(party: Party, sources: SenderNamingSources): string | null { - const db = sources.db +/** The task its dispatch was given: the federated `dispatch:` address, or the dispatch a local + * worker holds, else the one it last held, since its own `worker_done` settles that dispatch + * before the report is delivered. A handle is issued per run, so its latest is this run's. */ +function dispatchTaskName( + party: Party, + federated: boolean, + terminal: TerminalSenderNaming | null, + db: OrchestrationDb | null +): string | null { if (!db) { return null } - const dispatch = isDispatchAddress(party.address) + const handle = party.terminalHandle + const dispatch = federated ? db.getDispatchContextById(party.address.slice('dispatch:'.length)) - : party.terminalHandle - ? db.getActiveDispatchForTerminal( - party.terminalHandle, - sources.terminal(party.terminalHandle)?.paneKey ?? undefined - ) + : handle + ? (db.getActiveDispatchForTerminal(handle, terminal?.paneKey ?? undefined) ?? + db.getLatestDispatchForTerminal(handle)) : undefined const task = dispatch ? db.getTask(dispatch.task_id) : undefined return task?.display_name || task?.task_title || null } - -function isDispatchAddress(address: string): boolean { - return address.startsWith('dispatch:') -} diff --git a/src/main/runtime/runtime-orchestration-sender-names.test.ts b/src/main/runtime/runtime-orchestration-sender-names.test.ts index 60cb6059eee9..5fe4fc021a13 100644 --- a/src/main/runtime/runtime-orchestration-sender-names.test.ts +++ b/src/main/runtime/runtime-orchestration-sender-names.test.ts @@ -11,6 +11,7 @@ import { import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' import { OrchestrationDb } from './orchestration/db' import { createRootDispatch } from './orchestration/db/root-dispatch-test-fixture' +import { reconcileLifecycleMessage } from './orchestration/lifecycle-reconciliation' import { RuntimeOrchestrationSenderNames } from './runtime-orchestration-sender-names' const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) @@ -88,6 +89,48 @@ describe("a sender's name, from what Orca shows for it", () => { expect(names().nameOf(terminalParty('term_worker'))).toBe('Parser port') }) + it('names a worker by its task after its own accepted worker_done settled that dispatch', () => { + const run = db.createRun({ + objective: 'o', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: null + }) + const task = db.createTask({ runId: run.id, spec: 'build it' }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: 'tab_term_worker:leaf', + processIncarnation: 'p:1', + worktreeId: WORKTREE, + effects: [], + setupState: 'not_applicable' + }) + db.markWorkerDispatchReady(started.dispatch.id) + const report = db.insertMessage({ + from: 'term_worker', + to: `run:${run.id}`, + subject: 'Done', + type: 'worker_done', + senderPaneKey: 'tab_term_worker:leaf', + runId: run.id, + payload: JSON.stringify({ + taskId: task.id, + dispatchId: started.dispatch.id, + outcome: 'succeeded' + }) + }) + // Settled synchronously at send, before the mail lane names who it is from. + expect(reconcileLifecycleMessage(db, report).action).toBe('completed') + expect(db.getDispatchContextById(started.dispatch.id)?.status).toBe('completed') + expect(names().nameOf(terminalParty('term_worker'))).toBe('build it') + }) + it("names a chat by the label its tab shows, the person's rename first", () => { session.unifiedTabs = { [WORKTREE]: [chatTab(null, 'Fix the login flow')] } expect(names().nameOf(chatParty)).toBe('Fix the login flow') diff --git a/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts b/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts index 2326d6248311..22af4ddb2a16 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts @@ -70,7 +70,9 @@ describe("a busy chat's orchestration pointer waits in its queue", () => { const chat = await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() const endTurn = await runningUserTurn(chat) - await finishWorker(taskId) + const dispatchId = await finishWorker(taskId) + // The report was accepted, so its dispatch settled before its mail was named. + expect(db.getDispatchContextById(dispatchId)?.status).toBe('completed') await vi.waitFor( async () => expect(await queuedCardTexts()).toEqual([ptyPointer(`run:${runId}`)]), WAIT @@ -83,7 +85,7 @@ describe("a busy chat's orchestration pointer waits in its queue", () => { senders: [ { party: { address: 'term_worker', terminalHandle: 'term_worker', orcaSessionId: null }, - // Named by the task its dispatch was given, through the real runtime's naming. + // Named by the task of the dispatch it just finished, through the real runtime's naming. name: 'build it' } ], diff --git a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts index 6fadd54a13be..bb21e6ee46d9 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts @@ -157,11 +157,11 @@ export async function userTexts(sessionId: string): Promise { ) } -/** A supervised terminal worker under the coordinator's Run, and its worker_done. */ +/** A supervised terminal worker under the coordinator's Run, and its worker_done; its dispatch id. */ export async function finishWorker( taskId: string, worker: { handle: string; paneKey: string } = { handle: 'term_worker', paneKey: WORKER_PANE } -): Promise { +): Promise { const started = db.createStartingWorkerDispatch({ creator: { kind: 'system' }, maxDepth: Number.MAX_SAFE_INTEGER, @@ -184,6 +184,7 @@ export async function finishWorker( type: 'worker_done', payload: JSON.stringify({ taskId, dispatchId: started.dispatch.id, outcome: 'succeeded' }) }) + return started.dispatch.id } export async function coordinatorRunAndTask(): Promise<{ runId: string; taskId: string }> { @@ -272,6 +273,10 @@ export function startRuntime(): OrcaRuntimeService { vi.spyOn(started, 'getTerminalPaneKey').mockImplementation((handle) => handle === 'term_worker' ? WORKER_PANE : handle === 'term_worker_2' ? WORKER_2_PANE : null ) + // The exact process `finishWorker` records, so a worker's report is accepted and settles. + vi.spyOn(started, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === 'term_worker' || handle === 'term_worker_2' ? `runtime_test:${handle}:1` : null + ) return started } diff --git a/src/renderer/src/lib/open-agent-message-sender.test.ts b/src/renderer/src/lib/open-agent-message-sender.test.ts index 4034a5c7fcba..3df0494bd7e5 100644 --- a/src/renderer/src/lib/open-agent-message-sender.test.ts +++ b/src/renderer/src/lib/open-agent-message-sender.test.ts @@ -95,8 +95,10 @@ function versionBlock(): Error { return Object.assign(new Error('update needed'), { code: RUNTIME_COMPAT_BLOCK_CODE }) } -const UNREACHABLE = AGENT_SESSION_WRITE_NOTICE_COPY.unreachable -const UNSUPPORTED = AGENT_SESSION_WRITE_NOTICE_COPY.unsupported +// Existing sentences, each translated whole: the cause and what to do, or which Orca to update. +const UNREACHABLE = `${AGENT_SESSION_WRITE_NOTICE_COPY.unreachable} ${AGENT_SESSION_WRITE_NOTICE_COPY.tryAgain}` +const UPDATE = AGENT_SESSION_WRITE_NOTICE_COPY.updateOrcaToOpenChat +const OLDER_HOST = AGENT_SESSION_WRITE_NOTICE_COPY.unsupported beforeEach(() => { vi.clearAllMocks() @@ -140,7 +142,7 @@ describe("opening a message's sender", () => { await open(terminal) mocks.focusRuntime.mockRejectedValueOnce(versionBlock()) await open(terminal) - expect(mocks.toastError.mock.calls.map(([text]) => text)).toEqual([UNREACHABLE, UNSUPPORTED]) + expect(mocks.toastError.mock.calls.map(([text]) => text)).toEqual([UNREACHABLE, UPDATE]) expect(mocks.paneUnavailable).not.toHaveBeenCalled() expect(mocks.unavailable).not.toHaveBeenCalled() }) @@ -171,15 +173,15 @@ describe("opening a message's sender", () => { await open(terminal) mocks.callRuntimeRpc.mockRejectedValueOnce(versionBlock()) await open(chat) - expect(mocks.toastError.mock.calls.map(([text]) => text)).toEqual([UNREACHABLE, UNSUPPORTED]) + expect(mocks.toastError.mock.calls.map(([text]) => text)).toEqual([UNREACHABLE, UPDATE]) expect(mocks.unavailable).not.toHaveBeenCalled() expect(mocks.gone).not.toHaveBeenCalled() }) - it('treats a location kind a newer host answers with as needing an update', async () => { + it('treats a location kind a newer host answers with as needing an update, of either side', async () => { mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'browser', url: 'x' } }) await open(terminal) - expect(mocks.toastError).toHaveBeenCalledWith(UNSUPPORTED) + expect(mocks.toastError).toHaveBeenCalledWith(UPDATE) expect(mocks.focusRenderer).not.toHaveBeenCalled() }) @@ -193,8 +195,9 @@ describe("opening a message's sender", () => { mocks.focusRenderer.mockReturnValue(true) await open(terminal) expect(mocks.focusRenderer).toHaveBeenCalledWith('term_a', 'env-host') + // Only here is the chat's host known to be the older side. await open(dispatch) - expect(mocks.toastError).toHaveBeenCalledWith(UNSUPPORTED) + expect(mocks.toastError).toHaveBeenCalledWith(OLDER_HOST) }) it('runs one open per sender at a time', async () => { diff --git a/src/renderer/src/lib/open-agent-message-sender.ts b/src/renderer/src/lib/open-agent-message-sender.ts index dc97dd000495..71aee1eb8e29 100644 --- a/src/renderer/src/lib/open-agent-message-sender.ts +++ b/src/renderer/src/lib/open-agent-message-sender.ts @@ -3,9 +3,8 @@ // the sender was addressed, and uses words that already exist. import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' -import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../shared/agent-session-write-notice-copy' +import { agentSessionWriteNoticeText } from '@/components/native-chat/agent-session-write-notice-text' import type { OrchestrationPartyLocation, OrchestrationPartyLocationResult @@ -30,7 +29,10 @@ import { import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' type Sender = AgentMessageSource['senders'][number] -type Lookup = OrchestrationPartyLocationResult | 'unsupported' | 'unreachable' +/** `older-host`: the chat's host predates the lookup. `update`: one side is too old, either one. + * `unreachable`: no answer at all. */ +type LookupFailure = 'older-host' | 'update' | 'unreachable' +type Lookup = OrchestrationPartyLocationResult | LookupFailure /** The answers that prove the terminal is gone; anything else proves nothing about it. */ const TERMINAL_GONE_CODES = new Set([ @@ -76,7 +78,7 @@ async function openSender( messageIds, getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }) ) - if (found === 'unsupported' || found === 'unreachable') { + if (typeof found === 'string') { showLookupFailure(found) return } @@ -97,7 +99,7 @@ async function openSender( } if (location.kind !== 'terminal') { // A kind a newer host answers with. - showLookupFailure('unsupported') + showLookupFailure('update') return } await focusTerminal(location.handle, environmentId) @@ -113,7 +115,7 @@ async function focusTerminal(handle: string, environmentId: string | null): Prom if (error instanceof RuntimeRpcCallError && TERMINAL_GONE_CODES.has(error.code)) { showAgentPaneUnavailable() } else { - showLookupFailure(isRuntimeCompatBlockError(error) ? 'unsupported' : 'unreachable') + showLookupFailure(isRuntimeCompatBlockError(error) ? 'update' : 'unreachable') } } } @@ -133,7 +135,7 @@ async function lookUpSender( if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { return lookUpOnOlderHost(party) } - return isRuntimeCompatBlockError(error) ? 'unsupported' : 'unreachable' + return isRuntimeCompatBlockError(error) ? 'update' : 'unreachable' } } @@ -148,29 +150,27 @@ function lookUpOnOlderHost(party: Sender['party']): Lookup { return located({ kind: 'chat', sessionId, worktreeId }) } } - return 'unsupported' + return 'older-host' } const handle = party.terminalHandle return handle && !handle.startsWith('dispatch:') && !handle.startsWith('run:') ? located({ kind: 'terminal', handle }) - : 'unsupported' + : 'older-host' } function located(location: OrchestrationPartyLocation): OrchestrationPartyLocationResult { return { location } } -/** Words for a sender of any kind: the host could not answer, or must be updated first. */ -function showLookupFailure(failure: 'unsupported' | 'unreachable'): void { +/** Words for a sender of any kind, each sentence translated whole as write notices are. */ +function showLookupFailure(failure: LookupFailure): void { toast.error( - failure === 'unsupported' - ? translate( - 'components.native-chat.writeNotice.unsupported', - AGENT_SESSION_WRITE_NOTICE_COPY.unsupported - ) - : translate( - 'components.native-chat.writeNotice.unreachable', - AGENT_SESSION_WRITE_NOTICE_COPY.unreachable - ) + agentSessionWriteNoticeText( + failure === 'older-host' + ? ['unsupported'] + : failure === 'update' + ? ['updateOrcaToOpenChat'] + : ['unreachable', 'tryAgain'] + ) ) } From cc32e42f7a58b233e36bc8dd5a774cd0d761c43d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:07:12 -0700 Subject: [PATCH 4/6] fix(native-chat): name a worker only by work it holds or just reported A worker with no active dispatch was named by the newest dispatch ever assigned to its terminal, at any status and any age: one that failed before it ran, or a task finished long ago that its later, unrelated mail kept showing. Now a worker is named by its active dispatch, else by the dispatch its own worker_done in the announced mail names (only if that dispatch is its own), else by its tab or agent as any terminal is. --- .../journal-unsent-send-hold.ts | 3 +- .../orca-runtime-stop-requested-pty-ids.ts | 3 +- .../orchestration-sender-name.ts | 38 ++++-- .../structured-mail-source.test.ts | 14 ++- .../orchestration/structured-mail-source.ts | 59 +++++++-- ...runtime-orchestration-sender-names.test.ts | 119 ++++++++++++------ .../runtime-orchestration-sender-names.ts | 40 +++--- 7 files changed, 202 insertions(+), 74 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index 2c6797fd5913..f2bf6c953f08 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -46,8 +46,7 @@ export function unsentSendKeptAsCard( return null } const { source } = submission - // Exactly 'user': never `readAgentSessionMessageSource`, whose fallback for what it cannot read - // is the person. + // Exactly 'user': never a fallback that reads a kind it does not know as the person's. const persons = source?.kind === USER_MESSAGE_SOURCE.kind || // A build before `source` was recorded: `client` was only ever a person's send. diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 54479e36d5da..cfb9f26e4ad5 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -256,7 +256,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId resolveStructuredTarget: (mailboxHandle) => this.resolveStructuredMailboxTarget(mailboxHandle), getCliCommand: localOrchestrationCliCommand, - senderName: (party) => this.orchestrationSenderNames.nameOf(party), + senderName: (party, reportedDispatchId) => + this.orchestrationSenderNames.nameOf(party, reportedDispatchId), host: createStructuredMailboxPointerHost() }) diff --git a/src/main/runtime/orchestration/orchestration-sender-name.ts b/src/main/runtime/orchestration/orchestration-sender-name.ts index f38edb44d7f4..559269336872 100644 --- a/src/main/runtime/orchestration/orchestration-sender-name.ts +++ b/src/main/runtime/orchestration/orchestration-sender-name.ts @@ -7,6 +7,7 @@ import type { AgentType } from '../../../shared/agent-status-types' import { formatAgentTypeLabel } from '../../../shared/agent-type-label' import type { Tab } from '../../../shared/tab-types' import type { OrchestrationDb } from './db' +import type { DispatchContextRow } from './types' import { lineageLiveSession, type AgentSessionRecordReader } from './structured-session-lineage' export type TerminalSenderNaming = { @@ -27,11 +28,16 @@ export type SenderNamingSources = { type Party = AgentMessageSender['party'] -export function orchestrationSenderName(party: Party, sources: SenderNamingSources): string | null { +/** `reportedDispatchId`: the dispatch the sender's own `worker_done` in the batch names. */ +export function orchestrationSenderName( + party: Party, + sources: SenderNamingSources, + reportedDispatchId?: string +): string | null { const federated = party.address.startsWith('dispatch:') const terminal = !federated && party.terminalHandle ? sources.terminal(party.terminalHandle) : null - const task = dispatchTaskName(party, federated, terminal, sources.db) + const task = dispatchTaskName(party, federated, terminal, sources.db, reportedDispatchId) if (task) { return task } @@ -50,24 +56,42 @@ export function orchestrationSenderName(party: Party, sources: SenderNamingSourc } /** The task its dispatch was given: the federated `dispatch:` address, or the dispatch a local - * worker holds, else the one it last held, since its own `worker_done` settles that dispatch - * before the report is delivered. A handle is issued per run, so its latest is this run's. */ + * worker holds, else the one its own `worker_done` reports, since that report settles its dispatch + * before it is delivered. Never a dispatch the worker merely held once: one may have failed + * before it ran, and an old one names work long done. */ function dispatchTaskName( party: Party, federated: boolean, terminal: TerminalSenderNaming | null, - db: OrchestrationDb | null + db: OrchestrationDb | null, + reportedDispatchId: string | undefined ): string | null { if (!db) { return null } const handle = party.terminalHandle + const paneKey = terminal?.paneKey ?? undefined const dispatch = federated ? db.getDispatchContextById(party.address.slice('dispatch:'.length)) : handle - ? (db.getActiveDispatchForTerminal(handle, terminal?.paneKey ?? undefined) ?? - db.getLatestDispatchForTerminal(handle)) + ? (db.getActiveDispatchForTerminal(handle, paneKey) ?? + reportedDispatch(db, reportedDispatchId, handle, paneKey)) : undefined const task = dispatch ? db.getTask(dispatch.task_id) : undefined return task?.display_name || task?.task_title || null } + +/** Only the sender's own dispatch: a report's payload is the sender's to write. */ +function reportedDispatch( + db: OrchestrationDb, + dispatchId: string | undefined, + handle: string, + paneKey: string | undefined +): DispatchContextRow | undefined { + const dispatch = dispatchId ? db.getDispatchContextById(dispatchId) : undefined + return dispatch && + (dispatch.assignee_handle === handle || + (paneKey !== undefined && dispatch.assignee_pane_key === paneKey)) + ? dispatch + : undefined +} diff --git a/src/main/runtime/orchestration/structured-mail-source.test.ts b/src/main/runtime/orchestration/structured-mail-source.test.ts index fb55368dfae7..d9b9b4e7fb0e 100644 --- a/src/main/runtime/orchestration/structured-mail-source.test.ts +++ b/src/main/runtime/orchestration/structured-mail-source.test.ts @@ -11,9 +11,15 @@ describe('who delivered mail is from', () => { mailboxHandle: 'run:r1', dispatchId: null, batch: [ - { id: 'm1', from_handle: 'term_a', run_id: 'r1' }, - { id: 'm2', from_handle: `orca_session_id:${SESSION}`, run_id: 'r2' }, - { id: 'm3', from_handle: 'term_a', run_id: 'r1' } + { id: 'm1', from_handle: 'term_a', run_id: 'r1', type: 'status', payload: null }, + { + id: 'm2', + from_handle: `orca_session_id:${SESSION}`, + run_id: 'r2', + type: 'status', + payload: null + }, + { id: 'm3', from_handle: 'term_a', run_id: 'r1', type: 'status', payload: null } ], senderName: (party) => (party.terminalHandle === 'term_a' ? 'Build tab' : null) }) @@ -52,7 +58,7 @@ describe('who delivered mail is from', () => { db: null, mailboxHandle: 'run:r1', dispatchId: null, - batch: [{ id: 'm1', from_handle: 'term_a', run_id: 'r1' }], + batch: [{ id: 'm1', from_handle: 'term_a', run_id: 'r1', type: 'status', payload: null }], senderName }).senders[0]?.name expect(named(() => ` Build\n${'x'.repeat(400)}`)).toHaveLength(200) diff --git a/src/main/runtime/orchestration/structured-mail-source.ts b/src/main/runtime/orchestration/structured-mail-source.ts index a0c402c759fc..da9a7cc94cb0 100644 --- a/src/main/runtime/orchestration/structured-mail-source.ts +++ b/src/main/runtime/orchestration/structured-mail-source.ts @@ -13,20 +13,28 @@ import { import type { MessageRow, OrchestrationDb } from './db' import { resolveOrchestrationParty } from './orchestration-party' -export type MailSourceMessage = Pick +export type MailSourceMessage = Pick< + MessageRow, + 'id' | 'from_handle' | 'run_id' | 'type' | 'payload' +> -/** A name from Orca's records for a party (never an agent-painted title); null when it has none. */ -export type SenderNameResolver = (party: AgentMessageSender['party']) => string | null +/** A name from Orca's records for a party (never an agent-painted title); null when it has none. + * `reportedDispatchId`: the dispatch the sender's own `worker_done` among these messages names. */ +export type SenderNameResolver = ( + party: AgentMessageSender['party'], + reportedDispatchId?: string +) => string | null /** The one way a sender is recorded on a message: its party as orchestration resolves the * address, and a bounded snapshot of its name for when it is gone. */ export function agentMessageSender( address: string, db: OrchestrationDb | null, - senderName: SenderNameResolver + senderName: SenderNameResolver, + reportedDispatchId?: string ): AgentMessageSender { const party = senderParty(address, db) - return { party, name: snapshotName(senderName, party) } + return { party, name: snapshotName(senderName, party, reportedDispatchId) } } export function structuredMailSource(input: { @@ -39,7 +47,15 @@ export function structuredMailSource(input: { 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)) + senders.set( + address, + agentMessageSender( + address, + input.db, + input.senderName, + reportedDispatchId(input.batch, address) + ) + ) } } return { @@ -68,12 +84,39 @@ function senderParty(address: string, db: OrchestrationDb | null): AgentMessageS } } +/** 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 + } + 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 + } + } catch { + // A payload that does not parse names no dispatch. + } + } + return undefined +} + function snapshotName( senderName: SenderNameResolver, - party: AgentMessageSender['party'] + party: AgentMessageSender['party'], + reportedDispatchId: string | undefined ): string | null { try { - return agentMessageSenderName(senderName(party)) + return agentMessageSenderName(senderName(party, reportedDispatchId)) } catch { // A name is a label, never a reason the mail is not delivered. return null diff --git a/src/main/runtime/runtime-orchestration-sender-names.test.ts b/src/main/runtime/runtime-orchestration-sender-names.test.ts index 5fe4fc021a13..3958493f1d2d 100644 --- a/src/main/runtime/runtime-orchestration-sender-names.test.ts +++ b/src/main/runtime/runtime-orchestration-sender-names.test.ts @@ -13,6 +13,8 @@ import { OrchestrationDb } from './orchestration/db' import { createRootDispatch } from './orchestration/db/root-dispatch-test-fixture' import { reconcileLifecycleMessage } from './orchestration/lifecycle-reconciliation' import { RuntimeOrchestrationSenderNames } from './runtime-orchestration-sender-names' +import { structuredMailSource } from './orchestration/structured-mail-source' +import type { MessageRow } from './orchestration/types' const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ @@ -45,6 +47,64 @@ function names() { }) } +/** A worker dispatched `spec` on its own terminal, as `worker-start` leaves it. */ +function finishedWorker(spec: string, handle = 'term_worker') { + const run = db.createRun({ + objective: 'o', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: null + }) + const task = db.createTask({ runId: run.id, spec }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle, + paneKey: `tab_${handle}:leaf`, + processIncarnation: 'p:1', + worktreeId: WORKTREE, + effects: [], + setupState: 'not_applicable' + }) + db.markWorkerDispatchReady(started.dispatch.id) + return { runId: run.id, taskId: task.id, dispatchId: started.dispatch.id } +} + +/** Mail from `term_worker`, from its pane; a report names its task and dispatch. */ +function workerMessage( + runId: string, + type: 'worker_done' | 'status', + report?: { taskId: string; dispatchId: string } +) { + return db.insertMessage({ + from: 'term_worker', + to: `run:${runId}`, + subject: type, + type, + senderPaneKey: 'tab_term_worker:leaf', + runId, + ...(report ? { payload: JSON.stringify({ ...report, outcome: 'succeeded' }) } : {}) + }) +} + +/** The name the mail lane records for `term_worker` when it announces these messages. */ +function announcedName(batch: MessageRow[]): string | null { + const sources = names() + return ( + structuredMailSource({ + db, + mailboxHandle: 'run:r', + dispatchId: null, + batch, + senderName: (party, reported) => sources.nameOf(party, reported) + }).senders[0]?.name ?? null + ) +} + function chatTab(customLabel: string | null, label: string) { return { contentType: 'agent-session' as const, entityId: CHAT, customLabel, label } } @@ -90,45 +150,30 @@ describe("a sender's name, from what Orca shows for it", () => { }) it('names a worker by its task after its own accepted worker_done settled that dispatch', () => { - const run = db.createRun({ - objective: 'o', - coordinatorHandle: 'term_coord', - coordinatorPaneKey: null - }) - const task = db.createTask({ runId: run.id, spec: 'build it' }) - const started = db.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - db.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: 'term_worker', - paneKey: 'tab_term_worker:leaf', - processIncarnation: 'p:1', - worktreeId: WORKTREE, - effects: [], - setupState: 'not_applicable' - }) - db.markWorkerDispatchReady(started.dispatch.id) - const report = db.insertMessage({ - from: 'term_worker', - to: `run:${run.id}`, - subject: 'Done', - type: 'worker_done', - senderPaneKey: 'tab_term_worker:leaf', - runId: run.id, - payload: JSON.stringify({ - taskId: task.id, - dispatchId: started.dispatch.id, - outcome: 'succeeded' - }) - }) + const { runId, taskId, dispatchId } = finishedWorker('build it') + const report = workerMessage(runId, 'worker_done', { taskId, dispatchId }) // Settled synchronously at send, before the mail lane names who it is from. expect(reconcileLifecycleMessage(db, report).action).toBe('completed') - expect(db.getDispatchContextById(started.dispatch.id)?.status).toBe('completed') - expect(names().nameOf(terminalParty('term_worker'))).toBe('build it') + expect(db.getDispatchContextById(dispatchId)?.status).toBe('completed') + expect(announcedName([report])).toBe('build it') + }) + + it('never names a worker by a task it never ran, nor by work it finished long ago', () => { + const { runId, taskId, dispatchId } = finishedWorker('task A') + const report = workerMessage(runId, 'worker_done', { taskId, dispatchId }) + expect(reconcileLifecycleMessage(db, report).action).toBe('completed') + // Its next message, unrelated to that finished task: no task names it. + expect(announcedName([workerMessage(runId, 'status')])).toBe('Codex') + // A second task dispatched to the same terminal fails before the worker runs it. + const taskB = db.createTask({ runId, spec: 'task B' }) + const failed = createRootDispatch(db, taskB.id, 'term_worker') + db.failDispatch(failed.id, 'delivery failed') + expect(announcedName([workerMessage(runId, 'status')])).toBe('Codex') + }) + + it('never names a worker by a dispatch its report names that is not its own', () => { + const other = finishedWorker('someone else', 'term_other') + expect(announcedName([workerMessage(other.runId, 'worker_done', other)])).toBe('Codex') }) it("names a chat by the label its tab shows, the person's rename first", () => { diff --git a/src/main/runtime/runtime-orchestration-sender-names.ts b/src/main/runtime/runtime-orchestration-sender-names.ts index 8faba932ed26..7162f2d90a42 100644 --- a/src/main/runtime/runtime-orchestration-sender-names.ts +++ b/src/main/runtime/runtime-orchestration-sender-names.ts @@ -36,23 +36,33 @@ type MirroredTabs = { export class RuntimeOrchestrationSenderNames { constructor(private readonly deps: RuntimeSenderNameDeps) {} - /** A sender recorded on a message, named now. */ - sender(address: string): AgentMessageSender { - return agentMessageSender(address, this.deps.getDb(), (party) => this.nameOf(party)) + /** A sender recorded on a message, named now. `reportedDispatchId`: the dispatch the sender's + * own `worker_done` in that message names. */ + sender(address: string, reportedDispatchId?: string): AgentMessageSender { + return agentMessageSender( + address, + this.deps.getDb(), + (party, reported) => this.nameOf(party, reported), + reportedDispatchId + ) } - nameOf(party: AgentMessageSender['party']): string | null { - return orchestrationSenderName(party, { - db: this.deps.getDb(), - records: readAgentSessionRecordStore(), - chatTab: (worktreeId, sessionId) => - this.deps - .getWorkspaceSession(worktreeId) - ?.unifiedTabs?.[worktreeId]?.find( - (tab) => tab.contentType === 'agent-session' && tab.entityId === sessionId - ) ?? null, - terminal: (handle) => this.terminalNaming(handle) - }) + nameOf(party: AgentMessageSender['party'], reportedDispatchId?: string): string | null { + return orchestrationSenderName( + party, + { + db: this.deps.getDb(), + records: readAgentSessionRecordStore(), + chatTab: (worktreeId, sessionId) => + this.deps + .getWorkspaceSession(worktreeId) + ?.unifiedTabs?.[worktreeId]?.find( + (tab) => tab.contentType === 'agent-session' && tab.entityId === sessionId + ) ?? null, + terminal: (handle) => this.terminalNaming(handle) + }, + reportedDispatchId + ) } private terminalNaming(handle: string): TerminalSenderNaming | null { From 2f75dcc87458f7dcf60bd7e073e5af706e2be81b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:14:17 -0700 Subject: [PATCH 5/6] fix(native-chat): restore a rewound message's sender through its typed body; mobile card fixtures name their attribution - The rewind's held-sender restore lives with the retained rows and reads both bodies through the journal's message-body check, instead of a shared helper taking any object (anti-slop: no-object-parameters). - Mobile queued-card test fixtures carry the attribution field the card gained. --- ...se-mobile-native-chat-queued-slot.test.tsx | 3 ++- ...e-structured-agent-session-queued.test.tsx | 3 ++- .../structured-rewind-retained-host-rows.ts | 25 ++++++++++++++++--- src/shared/agent-session-message-source.ts | 13 ---------- 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx index 002a567548f6..d623953c0e9b 100644 --- a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx @@ -33,7 +33,8 @@ const CARD = { state: 'waiting' as const, paused: false, needsAttention: false, - caption: null + caption: null, + attribution: null } function Slot({ diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index fe7f7f1eb14a..0af6a74fffbd 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -533,7 +533,8 @@ describe('mobile structured queued messages', () => { state: 'waiting', paused: false, needsAttention: false, - caption: null + caption: null, + attribution: null } ]) // A frame without the field leaves the list alone; null empties it. diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts index bd7174e5076d..f41e83522d2c 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts @@ -6,7 +6,10 @@ import { parseCodexGoalJournalItemId } from '../../codex/codex-goal-journal-identity' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { AGENT_JOURNAL_ITEM_BODY_KINDS } from '../../../shared/agent-session-journal-schemas' +import { + AGENT_JOURNAL_ITEM_BODY_KINDS, + isAdmissibleAgentJournalMessageBody +} from '../../../shared/agent-session-journal-schemas' import type { AgentJournalItemBody, AgentJournalItemIdentity, @@ -16,7 +19,6 @@ import type { import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { restoreRewindJournalBody } from './structured-rewind-journal-body' -import { withRecordedSender } from '../../../shared/agent-session-message-source' type RetainedRow = AgentSessionRewindRecord['retained'][number] @@ -100,7 +102,24 @@ function withHeldAttribution(item: RetainedRow, held: RetainedRow | undefined): return item } const { itemId: _itemId, body: heldBody, ...attribution } = held - return { ...item, ...attribution, body: withRecordedSender(item.body, heldBody) } + return { ...item, ...attribution, body: withHeldSender(item.body, heldBody) } +} + +/** The provider's copy of a user message, with the sender the host recorded on it put back: no + * provider ever carries it. */ +function withHeldSender( + provider: RetainedRow['body'], + held: RetainedRow['body'] +): RetainedRow['body'] { + if ( + !isAdmissibleAgentJournalMessageBody(held) || + !isAdmissibleAgentJournalMessageBody(provider) + ) { + return provider + } + return held.from !== undefined && held.role === 'user' && provider.role === 'user' + ? Object.assign({}, provider, { from: held.from }) + : provider } /** Provider turn id → the item id of its turn record: the turn's own, or the command turn that diff --git a/src/shared/agent-session-message-source.ts b/src/shared/agent-session-message-source.ts index 86e95f4af345..d86d244df0a6 100644 --- a/src/shared/agent-session-message-source.ts +++ b/src/shared/agent-session-message-source.ts @@ -96,19 +96,6 @@ export function readAgentMessageSource(value: unknown): AgentMessageSource | und return parsed.success ? parsed.data : undefined } -/** A provider's copy of a user message the host recorded: the provider's body, with the sender the - * host stamped put back, since no provider ever carries it. */ -export function withRecordedSender(provider: T, recorded: object): T { - const from = 'from' in recorded ? recorded.from : undefined - return from !== undefined && isUserMessage(recorded) && isUserMessage(provider) - ? { ...provider, from } - : provider -} - -function isUserMessage(body: object): boolean { - return 'kind' in body && body.kind === 'message' && 'role' in body && body.role === 'user' -} - /** The senders a label names, and how many more it counts; one notice can carry many. */ export function agentMessageSendersShown( source: AgentMessageSource, From 255c8733a19acebd4382bd5de99ace67147e1922 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:35:24 -0700 Subject: [PATCH 6/6] fix(native-chat): never call a sender gone without proof; the sender builder moves out of the mail lane - A federated sender (dispatch:) runs on another host, which the chat's host cannot open: its name is plain text, and the host's lookup answers 'not found' rather than 'gone' for a dispatch it does not run. Not seeing a worker is no evidence it stopped. - The lookup calls a chat gone only when the identity is proven lost; any other failure reaches the client as 'could not reach'. - A sender open that throws shows 'couldn't reach the agent' instead of an unhandled rejection. - agentMessageSender, the one sender builder, lives in its own module, so the runtime's naming no longer imports from the mail lane. --- .../orchestration/agent-message-sender.ts | 51 ++++++++++++++++++ .../orchestration-party-location.test.ts | 43 ++++++++++++++- .../orchestration-party-location.ts | 18 +++++-- .../orchestration/structured-mail-source.ts | 52 ++----------------- .../structured-mailbox-pointer-delivery.ts | 3 +- .../orchestration-party-addressing.test.ts | 2 +- .../runtime-orchestration-sender-names.ts | 2 +- .../NativeChatAgentMessageRow.test.tsx | 12 +++++ .../NativeChatAgentMessageSenders.tsx | 10 +++- .../src/lib/open-agent-message-sender.test.ts | 20 +++++++ .../src/lib/open-agent-message-sender.ts | 15 ++++-- src/shared/orchestration-caller-status.ts | 4 +- 12 files changed, 166 insertions(+), 66 deletions(-) create mode 100644 src/main/runtime/orchestration/agent-message-sender.ts diff --git a/src/main/runtime/orchestration/agent-message-sender.ts b/src/main/runtime/orchestration/agent-message-sender.ts new file mode 100644 index 000000000000..b2be38933271 --- /dev/null +++ b/src/main/runtime/orchestration/agent-message-sender.ts @@ -0,0 +1,51 @@ +// The one way any Orca send that speaks for another agent records who it is from: the sender's +// party as orchestration resolves its address, and a bounded snapshot of its name. + +import { parseOrcaSessionAddress } from '../../../shared/orca-session-address' +import { + agentMessageSenderName, + type AgentMessageSender +} from '../../../shared/agent-session-message-source' +import type { OrchestrationDb } from './db' +import { resolveOrchestrationParty } from './orchestration-party' + +/** A name from Orca's records for a party (never an agent-painted title); null when it has none. + * `reportedDispatchId`: the dispatch the sender's own `worker_done` in the message names. */ +export type SenderNameResolver = ( + party: AgentMessageSender['party'], + reportedDispatchId?: string +) => string | null + +/** A sender as a message records it, named now, for when it is gone. */ +export function agentMessageSender( + address: string, + db: OrchestrationDb | null, + senderName: SenderNameResolver, + reportedDispatchId?: string +): AgentMessageSender { + const party = senderParty(address, db) + return { party, name: snapshotName(senderName, party, reportedDispatchId) } +} + +function senderParty(address: string, db: OrchestrationDb | null): AgentMessageSender['party'] { + try { + const { paneKey: _credential, ...party } = resolveOrchestrationParty(address, db) + return party + } catch { + // A worker this host lost the identity of: what the address itself says. + return { address, terminalHandle: null, orcaSessionId: parseOrcaSessionAddress(address) } + } +} + +function snapshotName( + senderName: SenderNameResolver, + party: AgentMessageSender['party'], + reportedDispatchId: string | undefined +): string | null { + try { + return agentMessageSenderName(senderName(party, reportedDispatchId)) + } catch { + // A name is a label, never a reason the message is not delivered. + return null + } +} diff --git a/src/main/runtime/orchestration/orchestration-party-location.test.ts b/src/main/runtime/orchestration/orchestration-party-location.test.ts index 85f0d0b7f534..c51bd3046a4b 100644 --- a/src/main/runtime/orchestration/orchestration-party-location.test.ts +++ b/src/main/runtime/orchestration/orchestration-party-location.test.ts @@ -1,6 +1,6 @@ // Where a message's sender opens: the host's read of a party, over the records production keeps. -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionLeaseFixture, @@ -10,6 +10,17 @@ import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fix import { OrchestrationDb } from './db' import { createRootDispatch } from './db/root-dispatch-test-fixture' import { locateOrchestrationParty } from './orchestration-party-location' +import { OrchestrationError } from './orchestration-error' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import type * as OrchestrationParty from './orchestration-party' + +// The real resolver, which a test can make fail once. +const resolveParty = vi.hoisted(() => vi.fn()) +vi.mock('./orchestration-party', async (importOriginal) => { + const actual = await importOriginal() + resolveParty.mockImplementation(actual.resolveOrchestrationParty) + return { ...actual, resolveOrchestrationParty: resolveParty } +}) import type { AgentSessionRecordReader } from './structured-session-lineage' const ROOT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') @@ -115,6 +126,34 @@ describe('where a sender opens', () => { expect(locate(`dispatch:${dispatch.id}`)).toEqual({ location: { kind: 'terminal', handle: 'term_now' } }) - expect(locate('dispatch:gone')).toEqual({ location: null, lost: 'terminal' }) + expect(locate('dispatch:gone')).toEqual({ location: null }) + }) + + it('never calls a dispatch this host does not run gone: a federated one has no assignee here', () => { + const run = db.createRun({ + objective: 'o', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: null + }) + const task = db.createTask({ runId: run.id, spec: 'remote work' }) + // At the run's home, a federated worker's dispatch is a row with no local assignee. + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + expect(locate(`dispatch:${started.dispatch.id}`)).toEqual({ location: null }) + }) + + it('calls a chat gone only on proof, and leaves any other failure to the caller', () => { + resolveParty.mockImplementationOnce(() => { + throw new OrchestrationError(CODES.notLive, 'lost its identity') + }) + expect(locate(`orca_session_id:${ROOT}`)).toEqual({ location: null, lost: 'chat' }) + resolveParty.mockImplementationOnce(() => { + throw new Error('database is locked') + }) + expect(() => locate(`orca_session_id:${ROOT}`)).toThrow('database is locked') }) }) diff --git a/src/main/runtime/orchestration/orchestration-party-location.ts b/src/main/runtime/orchestration/orchestration-party-location.ts index ea1698f11bd8..e4d632a1cfb6 100644 --- a/src/main/runtime/orchestration/orchestration-party-location.ts +++ b/src/main/runtime/orchestration/orchestration-party-location.ts @@ -4,6 +4,8 @@ import type { OrchestrationPartyLocationResult } from '../../../shared/orchestra import type { OrcaSessionId } from '../../../shared/orca-session-address' import type { OrchestrationDb } from './db' import { resolveOrchestrationParty } from './orchestration-party' +import { OrchestrationError } from './orchestration-error' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' import { lineageLiveSession, type AgentSessionRecordReader } from './structured-session-lineage' type PartyLocationDeps = { @@ -27,9 +29,13 @@ export function locateOrchestrationParty( let party: ReturnType try { party = resolveOrchestrationParty(address, deps.db) - } catch { - // A worker this host lost the identity of. - return { location: null, lost: 'chat' } + } catch (error) { + // Only a worker this host proves it lost the identity of is gone; any other failure is the + // caller's to report as unreachable. + if (error instanceof OrchestrationError && error.code === CODES.notLive) { + return { location: null, lost: 'chat' } + } + throw error } if (party.orcaSessionId) { return locateSession(party.orcaSessionId, deps.records) @@ -61,7 +67,9 @@ function locateTerminal( } /** As mail to the dispatch is routed (mailbox-delivery-target.ts): its assignee's pane, else - * its recorded handle, else a remote attachment's. */ + * its recorded handle, else a remote attachment's. A dispatch whose worker this host does not run + * (a federated one, at its run's home) is unknown here, never gone: not seeing a worker is no + * evidence it stopped. */ function locateDispatchAssignee( dispatchId: string, deps: PartyLocationDeps @@ -78,7 +86,7 @@ function locateDispatchAssignee( remote?.terminal_handle return handle && !handle.startsWith('dispatch:') ? locateOrchestrationParty(handle, deps) - : { location: null, lost: 'terminal' } + : { location: null } } function locateSession( diff --git a/src/main/runtime/orchestration/structured-mail-source.ts b/src/main/runtime/orchestration/structured-mail-source.ts index da9a7cc94cb0..624ecc9d16b0 100644 --- a/src/main/runtime/orchestration/structured-mail-source.ts +++ b/src/main/runtime/orchestration/structured-mail-source.ts @@ -4,39 +4,18 @@ * and message ids join back to orchestration's own rows while those exist. */ -import { parseOrcaSessionAddress } from '../../../shared/orca-session-address' -import { - agentMessageSenderName, - type AgentMessageSource, - type AgentMessageSender +import type { + AgentMessageSource, + AgentMessageSender } from '../../../shared/agent-session-message-source' import type { MessageRow, OrchestrationDb } from './db' -import { resolveOrchestrationParty } from './orchestration-party' +import { agentMessageSender, type SenderNameResolver } from './agent-message-sender' export type MailSourceMessage = Pick< MessageRow, 'id' | 'from_handle' | 'run_id' | 'type' | 'payload' > -/** A name from Orca's records for a party (never an agent-painted title); null when it has none. - * `reportedDispatchId`: the dispatch the sender's own `worker_done` among these messages names. */ -export type SenderNameResolver = ( - party: AgentMessageSender['party'], - reportedDispatchId?: string -) => string | null - -/** The one way a sender is recorded on a message: its party as orchestration resolves the - * address, and a bounded snapshot of its name for when it is gone. */ -export function agentMessageSender( - address: string, - db: OrchestrationDb | null, - senderName: SenderNameResolver, - reportedDispatchId?: string -): AgentMessageSender { - const party = senderParty(address, db) - return { party, name: snapshotName(senderName, party, reportedDispatchId) } -} - export function structuredMailSource(input: { db: OrchestrationDb | null mailboxHandle: string @@ -74,16 +53,6 @@ export function structuredMailSource(input: { } } -function senderParty(address: string, db: OrchestrationDb | null): AgentMessageSender['party'] { - try { - const { paneKey: _credential, ...party } = resolveOrchestrationParty(address, db) - return party - } catch { - // A worker this host lost the identity of: what the address itself says. - return { address, terminalHandle: null, orcaSessionId: parseOrcaSessionAddress(address) } - } -} - /** The dispatch a sender's own `worker_done` here reports: the task it just finished, whose * dispatch that report already settled. */ function reportedDispatchId( @@ -109,16 +78,3 @@ function reportedDispatchId( } return undefined } - -function snapshotName( - senderName: SenderNameResolver, - party: AgentMessageSender['party'], - reportedDispatchId: string | undefined -): string | null { - try { - return agentMessageSenderName(senderName(party, reportedDispatchId)) - } catch { - // A name is a label, never a reason the mail is not delivered. - return null - } -} diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index 490b2e0e3ec8..0683a7c22d91 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -24,7 +24,8 @@ import { resolveStructuredPointerOperation, type StructuredPointerSubmission } from './structured-pointer-operation-id' -import { structuredMailSource, type SenderNameResolver } from './structured-mail-source' +import { structuredMailSource } from './structured-mail-source' +import type { SenderNameResolver } from './agent-message-sender' import { retainReasonForDispatch, structuredDispatchDelivered, diff --git a/src/main/runtime/rpc/orchestration-party-addressing.test.ts b/src/main/runtime/rpc/orchestration-party-addressing.test.ts index 7c4fe79a33de..0bdcbb0cbcc7 100644 --- a/src/main/runtime/rpc/orchestration-party-addressing.test.ts +++ b/src/main/runtime/rpc/orchestration-party-addressing.test.ts @@ -240,7 +240,7 @@ describe('every target param resolves both spellings of a party to one canonical }) expect( await as(undefined, 'orchestration.partyLocation', { address: 'dispatch:gone' }) - ).toEqual({ location: null, lost: 'terminal' }) + ).toEqual({ location: null }) }) it("partyLocation: a terminal handle from an earlier run opens as its pane's live terminal, by the mail it sent", async () => { diff --git a/src/main/runtime/runtime-orchestration-sender-names.ts b/src/main/runtime/runtime-orchestration-sender-names.ts index 7162f2d90a42..ed0c167cf6c4 100644 --- a/src/main/runtime/runtime-orchestration-sender-names.ts +++ b/src/main/runtime/runtime-orchestration-sender-names.ts @@ -10,7 +10,7 @@ import { orchestrationSenderName, type TerminalSenderNaming } from './orchestration/orchestration-sender-name' -import { agentMessageSender } from './orchestration/structured-mail-source' +import { agentMessageSender } from './orchestration/agent-message-sender' import { readAgentSessionRecordStore } from './orchestration/structured-session-lineage' type RuntimeSenderNameDeps = { diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx index 8f6e16df9a52..86fcf30a2cbe 100644 --- a/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx @@ -96,6 +96,18 @@ describe("another agent's message in the transcript", () => { expect(screen.getByText('+1')).toBeInTheDocument() }) + it('shows a sender on another host as plain text, since this host cannot open it', () => { + renderUserMessage( + from([ + { address: 'dispatch:d1', name: 'Port the parser' }, + { address: CODER.address, name: 'Coder' } + ]) + ) + expect(screen.getByText('Port the parser')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Port the parser' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Coder' })).toBeInTheDocument() + }) + it('shows the name as plain text where there is no chat to open it from', () => { renderUserMessage(from([{ address: CODER.address, name: 'Coder' }]), null) expect(screen.getByText('Coder')).toBeInTheDocument() diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx index caa49102e4dd..463e1f2c354d 100644 --- a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx @@ -9,7 +9,8 @@ import { import { agentMessageSenderLabel } from './native-chat-agent-message-sender-label' /** "Message from " over another agent's message; each name opens that agent. Plain text - * where the transcript has no chat to resolve the sender against. */ + * where the transcript has no chat to resolve the sender against, and for a sender that runs on + * another host. */ export function NativeChatAgentMessageSenders({ from, chatWorktreeId @@ -29,7 +30,7 @@ export function NativeChatAgentMessageSenders({ {shown.map((sender, index) => ( // Fragment keys: one sender's name and the separator after it. - {chatWorktreeId ? ( + {chatWorktreeId && opensFromHere(sender) ? (