diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 2a2489bbdbc2..97796a8a0e7f 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -7,6 +7,7 @@ import { projectStructuredItemToNativeChat } from '../../../src/shared/structure 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') @@ -119,6 +120,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 ec0a6d48b310..1d61a8117ed4 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -20,6 +20,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, @@ -132,7 +133,9 @@ function MobileNativeChatMessageImpl({ reasoningExpanded?: boolean onToggleReasoning?: (key: string) => void }): 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 @@ -204,7 +207,19 @@ function MobileNativeChatMessageImpl({ {/* A turn with no user bubble carries its bar above its first row. */} {turnStatusAbove ? statusRow : null} - + + {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..371758a525f1 --- /dev/null +++ b/mobile/src/session/mobile-agent-message-attribution.test.ts @@ -0,0 +1,57 @@ +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, an unnamed one as an agent, and counts the rest', () => { + expect(agentMessageAttribution('Message from', from(['Coder']))).toBe('Message from Coder') + expect(agentMessageAttribution('From', from([]))).toBe('From an agent') + expect(agentMessageAttribution('From', from(['A', 'B', 'C', 'D']))).toBe('From A, B, C +1') + }) + + 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, + 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..8c1729c33375 --- /dev/null +++ b/mobile/src/session/mobile-agent-message-attribution.ts @@ -0,0 +1,22 @@ +// 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) + // 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/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index 2acad402176c..a18034fa505d 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -61,6 +61,15 @@ export const styles = StyleSheet.create({ // The common cap for an open reasoning block (about ten lines). maxHeight: 240 }, + 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 987d9aeefd48..748dc785db9f 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -48,7 +48,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 843292e44210..a254e2b65290 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -13,6 +13,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 @@ -24,6 +26,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 { @@ -136,7 +140,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/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-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 902f565b6173..fef63417dd6f 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 { moveQueuedMessages, type QueuedMessagePositionMove } from './queued-message-positions' import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' @@ -117,7 +116,6 @@ export class JournalQueuedMessages { fingerprint: string hostInstance: string carriedFrom?: string - source: AgentSessionMessageSource holdReason?: QueuedMessageHoldReason }, receipt?: JournalOperationReceipt 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-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index cba9c9a9bd51..27951b308e92 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -337,7 +337,7 @@ export function buildJournalSubmissionRow(input: { ...(input.handoverRecorded ? { handoverRecorded: true } : {}), ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}), ...(input.origin !== undefined ? { origin: input.origin } : {}), - // The kind only: a caller's full source carries senders that stay host-only. + // The kind only; who the senders are is on the message body. ...(input.source !== undefined ? { source: { kind: input.source.kind } } : {}) } } 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 dda3cd716956..3857d10a4dbc 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/journal-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts index f559f07db12a..293519d3680f 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts @@ -84,7 +84,12 @@ function hold(journal: AgentSessionJournal, settle: UnsentSendHold = { cause: 'h /** Orchestration mail as the mailbox sends it: from another agent, naming its sender. */ const MAIL_SOURCE: AgentMessageSource = { kind: 'agent', - senders: [{ party: { address: 'agent:coordinator', terminalHandle: null, orcaSessionId: null } }], + senders: [ + { + party: { address: 'agent:coordinator', terminalHandle: null, orcaSessionId: null }, + name: null + } + ], orchestration: { message: 'mail-notice', mailbox: 'agent:worker', dispatchId: null, messages: [] } } @@ -237,8 +242,7 @@ describe('which sends an earlier host process left unsent are kept', () => { messageId: 'card', body: message('card text'), fingerprint: fingerprint(message('card text')), - hostInstance: 'proc-1', - source: USER_MESSAGE_SOURCE + hostInstance: 'proc-1' }) await earlier.appendSubmission( { @@ -338,15 +342,13 @@ describe('where kept sends go in the queue', () => { messageId: 'H', body: message('H'), fingerprint: fingerprint(message('H')), - hostInstance: 'proc-1', - source: USER_MESSAGE_SOURCE + hostInstance: 'proc-1' }) await earlier.queuedMessages.insert({ messageId: 'C', body: message('C'), fingerprint: fingerprint(message('C')), - hostInstance: 'proc-1', - source: USER_MESSAGE_SOURCE + hostInstance: 'proc-1' }) await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) await earlier.appendSubmission( @@ -374,8 +376,7 @@ describe('where kept sends go in the queue', () => { messageId: 'C', body: message('C'), fingerprint: fingerprint(message('C')), - hostInstance: 'proc-1', - source: USER_MESSAGE_SOURCE + hostInstance: 'proc-1' }) await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) await accept(earlier, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) @@ -390,7 +391,6 @@ describe('where kept sends go in the queue', () => { body: message('text of A'), fingerprint: fingerprint(message('text of A')), hostInstance: HOST, - source: USER_MESSAGE_SOURCE, holdReason: QUEUED_MESSAGE_PAUSED_KEPT, queuedAt: { epoch: 'epoch-1', 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 f08d8bb05c4c..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 @@ -16,7 +16,7 @@ import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import type { AgentSessionJournal } from './journal-store' import type { JournalRowTransactionHook } from './journal-row-writer' import type { QueuedMessagePositionMove } from './queued-message-positions' @@ -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. @@ -117,15 +116,12 @@ export async function holdUnsentSends( ? { messageId: clientMessageId, body: card.body, - fingerprint: structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: journal.queuedMessages.sessionId, - fields: { body: card.body } - }), + fingerprint: agentSessionSendBodyFingerprint( + journal.queuedMessages.sessionId, + card.body + ), hostInstance: input.hostInstance, holdReason: QUEUED_MESSAGE_PAUSED_KEPT, - // Only a person's send is kept. - source: USER_MESSAGE_SOURCE, queuedAt: { epoch, sequence: submission.acceptedSequence ?? 0 }, position: card.position } 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 f9bf0fb2d0bb..3553c0c9ff71 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 @@ -64,7 +64,6 @@ function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom body: message(messageId), fingerprint: `fp-${messageId}`, hostInstance: HOST, - source: { kind: 'user' }, ...(carriedFrom ? { carriedFrom } : {}) }) } @@ -497,8 +496,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') @@ -528,7 +526,6 @@ describe("a restart's pause", () => { body: message('kept across a restart'), fingerprint: 'fp-kept', hostInstance: 'proc-0', - source: { kind: 'user' }, holdReason: QUEUED_MESSAGE_PAUSED_KEPT }) expect(kept.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) 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 264d17b572a1..7160718124bc 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 @@ -23,7 +23,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 @@ -83,8 +83,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' }) } @@ -183,8 +182,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 body as written across reopen: the queue reads nothing of its sender", async () => { + const from: AgentMessageSource = { kind: 'agent', senders: [ { @@ -192,7 +191,8 @@ describe('draft rows', () => { address: 'structworker_1', terminalHandle: 'structworker_1', orcaSessionId: null - } + }, + name: 'Reviewer' } ], orchestration: { @@ -203,31 +203,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 - }) - await queueDraft(first, 'before-the-column') - await queueDraft(first, 'unreadable') + 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 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: 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 = ?" ) - 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: { message: 'task', taskId: 't1' } }], + ['malformed', 'nobody'] ]) + // 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-stored-row.ts b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts index f5a10c42073f..1e5fdb2ac03b 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts @@ -3,10 +3,6 @@ import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { - readAgentSessionMessageSource, - type AgentSessionMessageSource -} from '../../../shared/agent-session-message-source' import { readStoredRejectionFact } from './journal-dispatch-reducer' import type { QueuedMessageRow } from './queued-message-table' @@ -31,7 +27,6 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul carried_from: string | null queued_epoch: string | null queued_sequence: number | null - source_json: string | null } let body: AgentJournalMessageItem try { @@ -70,21 +65,10 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul 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-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index d68341888a6f..f3e82613b1f3 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 @@ -17,10 +17,6 @@ import { QUEUED_MESSAGE_PAUSED_KEPT, type QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../shared/agent-session-queued-message-wire' -import { - serializeAgentSessionMessageSource, - type AgentSessionMessageSource -} from '../../../shared/agent-session-message-source' import { rejectedDraftSettlement } from './journal-dispatch-settlement' import { readStoredQueuedMessageRow } from './queued-message-stored-row' @@ -70,12 +66,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, @@ -87,7 +81,6 @@ export function insertQueuedMessage( hostInstance: string carriedFrom?: string queuedAt: AgentJournalCursor - source: AgentSessionMessageSource now: number /** Absent: after every other card. */ position?: number @@ -101,7 +94,7 @@ export function insertQueuedMessage( const position = input.position ?? Number(highest?.p ?? 0) + 1 db.prepare( `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)` ).run( input.sessionId, input.messageId, @@ -113,8 +106,7 @@ export function insertQueuedMessage( input.holdReason ?? null, input.carriedFrom ?? null, input.queuedAt.epoch, - input.queuedAt.sequence, - serializeAgentSessionMessageSource(input.source) + input.queuedAt.sequence ) return { sessionId: input.sessionId, @@ -132,8 +124,7 @@ export function insertQueuedMessage( settledByOp: null, consumedAs: null, carriedFrom: input.carriedFrom ?? null, - queuedAt: input.queuedAt, - source: input.source + queuedAt: input.queuedAt } } 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 ddfd7c25ff4a..9eb3126c197c 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 { AgentSessionMessageSource } 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,10 +60,9 @@ 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 send is from. A queued one records it on - * its card, a direct one its kind on the submission. A client's send is always its person's - * (`userSend`). */ - source?: AgentSessionMessageSource + /** Host-local, never on the wire: a person's message the host sends for them, such as a + * launch's first prompt. `userSend` is always one; another agent's message carries `from`. */ + personsMessage?: true 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 6190d74b51b0..4f30c3f1f00d 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 @@ -9,6 +9,7 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { + AGENT_MESSAGE_SOURCE, USER_MESSAGE_SOURCE, type AgentSessionMessageSource } from '../../../shared/agent-session-message-source' @@ -23,7 +24,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 @@ -39,16 +43,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 @@ -75,14 +69,28 @@ export type MutationPlan = { } ) +/** Who a send is from, read off what it carries, the one place it is decided: the person's own + * send, another agent's message (its body names the sender), or neither, such as a dispatch + * preamble or a restart continuation. */ +function sendSource(params: { + body: AgentJournalMessageItem + userSend?: true + personsMessage?: true +}): AgentSessionMessageSource | undefined { + if (params.userSend || params.personsMessage) { + return USER_MESSAGE_SOURCE + } + return params.body.from ? AGENT_MESSAGE_SOURCE : undefined +} + export function sendPlan(params: { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem retryUnknown?: true delivery?: 'queue-if-active' userSend?: true - /** Who a host-side send is from; a person's send (`userSend`) is always the user. */ - source?: AgentSessionMessageSource + /** A person's message the host sends for them; `userSend` is always one. */ + personsMessage?: true beforeRun?: () => void }): MutationPlan { // The operation id IS the client message id: one send, one durable row, one @@ -95,19 +103,23 @@ 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. run: (ctx) => { // Asked at acceptance: a send accepted after this one is queued behind it. params.beforeRun?.() - const source = params.userSend ? USER_MESSAGE_SOURCE : params.source + const source = sendSource(params) return performSend(ctx, { origin: params.userSend ? 'client' : 'host', ...(source ? { source } : {}), 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 bf2d1a5575e2..58ef7a607ba0 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 { StructuredAgentSessionHost } from './structured-agent-session-host' import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' @@ -32,7 +33,7 @@ import { 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 }) @@ -83,16 +84,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 3d451a72e0f1..225467d2dbda 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 AgentSessionMessageSource -} 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?: AgentSessionMessageSource } ): 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 0eb300fdef81..33142a830a50 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 @@ -23,10 +23,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 @@ -116,10 +114,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, // A kept send stays held there too: no later message may release it. ...(row.holdReason === QUEUED_MESSAGE_PAUSED_KEPT ? { holdReason: QUEUED_MESSAGE_PAUSED_KEPT } 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 d27d521c40e8..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 { AgentSessionMessageSource } 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?: AgentSessionMessageSource }, 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 d53d06f35a00..b19f2a49d77c 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 @@ -559,8 +559,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/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts index 0219207a6ccb..2885cf1d4c8a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts @@ -33,7 +33,12 @@ import { /** Orchestration mail as the mailbox sends it: from another agent, naming its sender. */ const MAIL_SOURCE: AgentMessageSource = { kind: 'agent', - senders: [{ party: { address: 'agent:coordinator', terminalHandle: null, orcaSessionId: null } }], + senders: [ + { + party: { address: 'agent:coordinator', terminalHandle: null, orcaSessionId: null }, + name: null + } + ], orchestration: { message: 'mail-notice', mailbox: 'agent:worker', dispatchId: null, messages: [] } } @@ -518,13 +523,14 @@ describe('the same send arriving again after the restart', () => { }) describe('what is not kept', () => { - // Mail names its sender (an agent); a dispatch preamble names none. Neither is a person's. + // Mail names its sender on its body (an agent); a dispatch preamble names none. Neither is a + // person's, and the submission's kind is read off that. it.each([ - { by: 'mail', source: MAIL_SOURCE, recorded: { kind: 'agent' } }, - { by: 'dispatch', source: undefined, recorded: undefined } - ])('an orchestration $by send is rejected, as before', async ({ by, source, recorded }) => { + { by: 'mail', from: MAIL_SOURCE, recorded: { kind: 'agent' } }, + { by: 'dispatch', from: undefined, recorded: undefined } + ])('an orchestration $by send is rejected, as before', async ({ by, from, recorded }) => { const { userSend: _person, ...sent } = sendRequest(by) - await acceptWhileStarting({ ...sent, ...(source ? { source } : {}) }) + await acceptWhileStarting({ ...sent, ...(from ? { body: { ...sent.body, from } } : {}) }) await rig.crashRestartHostProcess() expect(await rig.drafts()).toEqual([]) const submission = await rig.submission(sent.envelope.clientOperationId) @@ -532,6 +538,18 @@ describe('what is not kept', () => { expect(submission?.source).toEqual(recorded) }) + // Not a client's send, but the person's: the host sends a launch's first prompt for them. + it("keeps a launch's first prompt, which the host sends for the person", async () => { + const { userSend: _client, ...sent } = sendRequest('the launch prompt') + await acceptWhileStarting({ ...sent, personsMessage: true }) + await rig.crashRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: sent.envelope.clientOperationId, ...KEPT }]) + expect(await rig.submission(sent.envelope.clientOperationId)).toMatchObject({ + origin: 'host', + source: { kind: 'user' } + }) + }) + it('a send whose card could not be written is rejected as before, and nothing stays queued', async () => { const id = await acceptWhileStarting(sendRequest('card write fails')) const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) 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..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, @@ -93,13 +96,30 @@ 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: 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/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 8b406b2df6a6..cfb9f26e4ad5 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,7 @@ import { OrchestrationStructuredMailboxPointerDelivery } from './orchestration/structured-mailbox-pointer-delivery' import { createStructuredMailboxPointerHost } from './orchestration/structured-mailbox-pointer-host' import { localOrchestrationCliCommand } from './orchestration/cli-command' +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' @@ -255,9 +256,20 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId resolveStructuredTarget: (mailboxHandle) => this.resolveStructuredMailboxTarget(mailboxHandle), getCliCommand: localOrchestrationCliCommand, + senderName: (party, reportedDispatchId) => + this.orchestrationSenderNames.nameOf(party, reportedDispatchId), host: createStructuredMailboxPointerHost() }) + /** 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({ 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/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 new file mode 100644 index 000000000000..c51bd3046a4b --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-party-location.test.ts @@ -0,0 +1,159 @@ +// Where a message's sender opens: the host's read of a party, over the records production keeps. + +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 './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') +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 }) + }) + + 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 new file mode 100644 index 000000000000..e4d632a1cfb6 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-party-location.ts @@ -0,0 +1,106 @@ +// Where a party named by its mailbox address is now, so a chat can open a message's sender. + +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' +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 = { + 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, + messageIds: readonly string[] = [] +): OrchestrationPartyLocationResult { + if (address.startsWith('dispatch:')) { + return locateDispatchAssignee(address.slice('dispatch:'.length), deps) + } + let party: ReturnType + try { + party = resolveOrchestrationParty(address, deps.db) + } 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) + } + 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 + * 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 +): OrchestrationPartyLocationResult { + 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) + : { location: null } +} + +function locateSession( + orcaSessionId: OrcaSessionId, + records: AgentSessionRecordReader | null +): OrchestrationPartyLocationResult { + const record = records ? lineageLiveSession(records, orcaSessionId) : null + return record + ? { + location: { + kind: 'chat', + sessionId: record.sessionId, + worktreeId: record.location.workspaceId + } + } + : { location: null, lost: 'chat' } +} 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-name.ts b/src/main/runtime/orchestration/orchestration-sender-name.ts new file mode 100644 index 000000000000..559269336872 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-sender-name.ts @@ -0,0 +1,97 @@ +// 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 type { DispatchContextRow } from './types' +import { lineageLiveSession, type AgentSessionRecordReader } from './structured-session-lineage' + +export type TerminalSenderNaming = { + /** 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 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'] + +/** `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, reportedDispatchId) + if (task) { + return task + } + if (party.orcaSessionId) { + const record = sources.records ? lineageLiveSession(sources.records, party.orcaSessionId) : null + if (record) { + const tab = sources.chatTab(record.location.workspaceId, record.sessionId) + return tab?.customLabel?.trim() || tab?.label.trim() || defaultAgentChatLabel(record.provider) + } + } + 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 the dispatch a local + * 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, + 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, 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/send-agent-turn-host.test.ts b/src/main/runtime/orchestration/send-agent-turn-host.test.ts index 9ba15ea1765c..df50c6fad03a 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 () => { @@ -138,11 +156,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 }) }) // An idle chat sends the mail at once: its submission says it is an agent's, without the senders 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..d9b9b4e7fb0e 100644 --- a/src/main/runtime/orchestration/structured-mail-source.test.ts +++ b/src/main/runtime/orchestration/structured-mail-source.test.ts @@ -11,21 +11,32 @@ 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) }) 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 +51,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', type: 'status', payload: null }], + 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..624ecc9d16b0 100644 --- a/src/main/runtime/orchestration/structured-mail-source.ts +++ b/src/main/runtime/orchestration/structured-mail-source.ts @@ -4,26 +4,37 @@ * and message ids join back to orchestration's own rows while those exist. */ -import { parseOrcaSessionAddress } from '../../../shared/orca-session-address' 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 +export type MailSourceMessage = Pick< + MessageRow, + 'id' | 'from_handle' | 'run_id' | 'type' | 'payload' +> 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) }) + senders.set( + address, + agentMessageSender( + address, + input.db, + input.senderName, + reportedDispatchId(input.batch, address) + ) + ) } } return { @@ -42,12 +53,28 @@ 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( + 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 } 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..0683a7c22d91 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' @@ -26,6 +25,7 @@ import { type StructuredPointerSubmission } from './structured-pointer-operation-id' import { structuredMailSource } from './structured-mail-source' +import type { SenderNameResolver } from './agent-message-sender' import { retainReasonForDispatch, structuredDispatchDelivered, @@ -67,8 +67,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 +86,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 +221,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 +256,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/agent-launch-structured-prompt.test.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts index 55717cca96b5..8b0a81df6102 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts @@ -53,7 +53,7 @@ describe('committing a launch prompt', () => { expect(params.envelope).toMatchObject({ sessionId: 'sess-1', expectedRuntimeFence: 4 }) expect(params.body).toEqual(structuredAgentSessionSendBody('do the thing', [])) // A restart keeps a launch's first prompt like a person's message, so the send says it is one. - expect(params.source).toEqual({ kind: 'user' }) + expect(params.personsMessage).toBe(true) // The host recomputes and compares this, so a launch send must fingerprint like a client send. expect(params.envelope.payloadFingerprint).toBe( structuredAgentSessionPayloadFingerprint({ diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts index e18f557163db..05f1f8a7b3a2 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -24,7 +24,6 @@ import { createStructuredAgentSessionOperationId } from '../../../../shared/stru import { randomUUID } from 'node:crypto' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' -import { USER_MESSAGE_SOURCE } from '../../../../shared/agent-session-message-source' /** * The committed transcript row's id, or `null` when nothing was committed. @@ -58,7 +57,7 @@ export async function commitStructuredAgentSessionLaunchPrompt(args: { const result = await args.host.send(args.caller, { ...structuredAgentSessionSendMutation(entry, args.fence), // A person's first prompt, sent for them: kept as a card if a restart or a close comes first. - source: USER_MESSAGE_SOURCE + personsMessage: true }) return result.ok ? result.value.clientMessageId : null } catch (error) { diff --git a/src/main/runtime/rpc/methods/orchestration/caller-show.ts b/src/main/runtime/rpc/methods/orchestration/caller-show.ts index c8284dc4d108..e7d801f50be0 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,21 @@ 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 => + 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 ddc5711c8274..0bdcbb0cbcc7 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,49 @@ 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: WORKER_HANDLE })).toEqual({ + location: { kind: 'terminal', handle: WORKER_HANDLE } + }) + expect( + await as(undefined, 'orchestration.partyLocation', { address: 'dispatch:gone' }) + ).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 () => { + 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' }) + }) }) 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/runtime-orchestration-sender-names.test.ts b/src/main/runtime/runtime-orchestration-sender-names.test.ts new file mode 100644 index 000000000000..3958493f1d2d --- /dev/null +++ b/src/main/runtime/runtime-orchestration-sender-names.test.ts @@ -0,0 +1,207 @@ +// 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 { 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', () => ({ + 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) + }) +} + +/** 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 } +} + +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 worker by its task after its own accepted worker_done settled that dispatch', () => { + 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(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", () => { + 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..ed0c167cf6c4 --- /dev/null +++ b/src/main/runtime/runtime-orchestration-sender-names.ts @@ -0,0 +1,83 @@ +// 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/agent-message-sender' +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. `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'], 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 { + 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 104f767e333f..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 @@ -78,10 +80,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 }, + // Named by the task of the dispatch it just finished, through the real runtime's naming. + name: 'build it' + } ], orchestration: { message: 'mail-notice', @@ -89,11 +95,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/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/components/native-chat/NativeChatAgentMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx new file mode 100644 index 000000000000..86fcf30a2cbe --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx @@ -0,0 +1,162 @@ +// @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' +import { queuedCardSenderLine } from './native-chat-agent-message-sender-label' + +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, 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(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', () => { + 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 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() + 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() + }) + + 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 new file mode 100644 index 000000000000..463e1f2c354d --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx @@ -0,0 +1,61 @@ +import { Fragment } from 'react' +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, and for a sender that runs on + * another host. */ +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, index) => ( + // Fragment keys: one sender's name and the separator after it. + + {chatWorktreeId && opensFromHere(sender) ? ( + + ) : ( + {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} +
+ ) +} + +/** A federated sender (`dispatch:`) runs on another host, which this chat's host cannot open. */ +function opensFromHere(sender: AgentMessageSource['senders'][number]): boolean { + return !sender.party.address.startsWith('dispatch:') +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index a19d41ad2a58..9e77a3b2082c 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -18,6 +18,7 @@ import { NativeChatCodeBlock } from './NativeChatCodeBlock' import { NativeChatNoticeRow } from './NativeChatNoticeRow' import { NativeChatCopyButton } from './NativeChatCopyButton' import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' +import { NativeChatAgentMessageSenders } from './NativeChatAgentMessageSenders' import { NativeChatAgentControls, NativeChatImageAttachments, @@ -177,10 +178,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..a2e44282382a --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts @@ -0,0 +1,25 @@ +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) + // 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/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 4dc58abf4908..986b8eb0981b 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 @@ -246,3 +246,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/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/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 fe2aff1199c7..fa5ff65063f0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18261,6 +18261,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", @@ -18319,7 +18323,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}}" }, "thought": "Thought", "thoughtForDuration": "Thought for {{duration}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cd2f8913930f..f5b38d7f7e89 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17950,7 +17950,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}}" }, "thought": "Pensó", "thoughtForDuration": "Pensó durante {{duration}}", @@ -18056,6 +18057,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 0ca533b16d70..b31785339e3c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18115,6 +18115,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", @@ -18167,7 +18171,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}}" }, "thought": "A réfléchi", "thoughtForDuration": "A réfléchi pendant {{duration}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7abc80e5c0b9..1ced96ba0529 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18051,6 +18051,10 @@ "used": "コンテキスト: {{used}} トークン使用済み、最後の応答から推定。", "unreported": "このセッションではコンテキストの使用量を表示できません。" }, + "agentMessage": { + "messageFrom": "送信元:", + "unnamedSender": "Agent" + }, "goal": { "placeholder": "目標を説明し、最良の結果を得るために測定可能な結果を​​定義する", "clear": "明確な目標", @@ -18103,7 +18107,8 @@ "queuePausedRestarted": "Orca が再起動したため、キューを一時停止しました", "queuePausedCleared": "会話を消去したため、キューを一時停止しました", "queuePaused": "キューは一時停止中です", - "resume": "再開" + "resume": "再開", + "from": "送信元: {{names}}" }, "thought": "考えました", "thoughtForDuration": "{{duration}} 考えました", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 73330f8e45fb..41cf3c0fd447 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18051,6 +18051,10 @@ "used": "컨텍스트: {{used}} 토큰 사용, 마지막 응답 기준 추정치입니다.", "unreported": "이 세션에서는 컨텍스트 사용량을 확인할 수 없습니다." }, + "agentMessage": { + "messageFrom": "보낸 사람:", + "unnamedSender": "Agent" + }, "goal": { "placeholder": "목표를 설명하고, 최상의 결과를 위해 측정 가능한 결과를 정의하세요.", "clear": "명확한 목표", @@ -18103,7 +18107,8 @@ "queuePausedRestarted": "Orca가 다시 시작되어 대기열이 일시 중지되었습니다", "queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다", "queuePaused": "대기열이 일시 중지되었습니다", - "resume": "재개" + "resume": "재개", + "from": "보낸 사람: {{names}}" }, "thought": "생각함", "thoughtForDuration": "{{duration}} 동안 생각함", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d141cf70610e..539b080998ad 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18016,6 +18016,10 @@ "used": "上下文:已用 {{used}} token,根据上一次响应的估算值。", "unreported": "此会话无法提供上下文用量。" }, + "agentMessage": { + "messageFrom": "消息来自", + "unnamedSender": "一个智能体" + }, "goal": { "placeholder": "描述您的目标,定义可衡量的结果以获得最佳结果", "clear": "明确的目标", @@ -18068,7 +18072,8 @@ "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", "queuePausedCleared": "清除对话后,队列已暂停", "queuePaused": "队列已暂停", - "resume": "继续" + "resume": "继续", + "from": "来自 {{names}}" }, "thought": "已思考", "thoughtForDuration": "思考了 {{duration}}", 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..7dffd495e96e --- /dev/null +++ b/src/renderer/src/lib/open-agent-message-sender.test.ts @@ -0,0 +1,232 @@ +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(), + 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, + 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 { 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' + +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 } + +/** 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', + ok: false, + error: { code, message: code }, + _meta: { runtimeId: 'runtime_1' } + }) +} + +function versionBlock(): Error { + return Object.assign(new Error('update needed'), { code: RUNTIME_COMPAT_BLOCK_CODE }) +} + +// 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() + mocks.tabs.set('current', {}) +}) + +describe("opening a message's sender", () => { + 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 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('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) + 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("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, UPDATE]) + 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 open(chat) + expect(mocks.activateChat).toHaveBeenCalledWith({ + structuredSession: { workspaceId: 'wt-sender', sessionId: 'live-session' } + }) + }) + + 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) + expect(mocks.paneUnavailable).toHaveBeenCalledTimes(1) + expect(mocks.activateChat).not.toHaveBeenCalled() + }) + + 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, 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, of either side', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'browser', url: 'x' } }) + await open(terminal) + expect(mocks.toastError).toHaveBeenCalledWith(UPDATE) + 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 open(chat) + expect(mocks.activateChat).toHaveBeenCalledWith({ + structuredSession: { workspaceId: 'wt-sender', sessionId: ROOT } + }) + 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(OLDER_HOST) + }) + + it('says it could not reach a sender the host cannot place, never that it is gone', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ location: null }) + await open(dispatch) + expect(mocks.toastError).toHaveBeenCalledWith(UNREACHABLE) + expect(mocks.paneUnavailable).not.toHaveBeenCalled() + expect(mocks.gone).not.toHaveBeenCalled() + }) + + it('reports an open that throws instead of leaving it unhandled', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ + location: { kind: 'chat', sessionId: 'live-session', worktreeId: 'wt-sender' } + }) + mocks.activateChat.mockRejectedValueOnce(new Error('store unavailable')) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await expect(open(chat)).resolves.toBeUndefined() + expect(mocks.toastError).toHaveBeenCalledWith(UNREACHABLE) + expect(warned).toHaveBeenCalled() + warned.mockRestore() + }) + + 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 = 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 new file mode 100644 index 000000000000..72112ffca0ae --- /dev/null +++ b/src/renderer/src/lib/open-agent-message-sender.ts @@ -0,0 +1,183 @@ +// 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. Feedback is chosen by what the host found, never by how +// the sender was addressed, and uses words that already exist. + +import { toast } from 'sonner' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { agentSessionWriteNoticeText } from '@/components/native-chat/agent-session-write-notice-text' +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 { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' +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 Sender = AgentMessageSource['senders'][number] +/** `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([ + '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( + 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(source, sender, chatWorktreeId) + .catch((error: unknown) => { + // Every answer above shows its own words; one that threw showed none. + console.warn('[agent-message-sender] opening the sender failed', error) + showLookupFailure('unreachable') + }) + .finally(() => opensInFlight.delete(key)) + opensInFlight.set(key, opening) + return opening +} + +async function openSender( + source: AgentMessageSource, + { party }: Sender, + chatWorktreeId: string +): Promise { + const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), chatWorktreeId) + // 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 (typeof found === 'string') { + showLookupFailure(found) + return + } + const { location } = found + if (!location) { + if (found.lost === 'chat') { + structuredSessionOpenFeedback.gone() + } else if (found.lost === 'terminal') { + showAgentPaneUnavailable() + } else { + // Not found, and not proven gone: one this host does not run. + showLookupFailure('unreachable') + } + return + } + if (location.kind === 'chat') { + await activateAiVaultStructuredSession({ + structuredSession: { workspaceId: location.worktreeId, sessionId: location.sessionId } + }) + return + } + if (location.kind !== 'terminal') { + // A kind a newer host answers with. + showLookupFailure('update') + return + } + await focusTerminal(location.handle, environmentId) +} + +async function focusTerminal(handle: string, environmentId: string | null): Promise { + if (focusRendererTerminalHandle(handle, environmentId)) { + return + } + try { + await focusRuntimeTerminalHandle(handle, environmentId) + } catch (error) { + if (error instanceof RuntimeRpcCallError && TERMINAL_GONE_CODES.has(error.code)) { + showAgentPaneUnavailable() + } else { + showLookupFailure(isRuntimeCompatBlockError(error) ? 'update' : 'unreachable') + } + } +} + +async function lookUpSender( + party: Sender['party'], + messageIds: readonly string[], + host: RuntimeClientTarget +): Promise { + try { + return await callRuntimeRpc( + host, + 'orchestration.partyLocation', + { address: party.address, ...(messageIds.length > 0 ? { messageIds } : {}) } + ) + } catch (error) { + if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { + return lookUpOnOlderHost(party) + } + return isRuntimeCompatBlockError(error) ? 'update' : 'unreachable' + } +} + +/** 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 located({ kind: 'chat', sessionId, worktreeId }) + } + } + return 'older-host' + } + const handle = party.terminalHandle + return handle && !handle.startsWith('dispatch:') && !handle.startsWith('run:') + ? located({ kind: 'terminal', handle }) + : 'older-host' +} + +function located(location: OrchestrationPartyLocation): OrchestrationPartyLocationResult { + return { location } +} + +/** Words for a sender of any kind, each sentence translated whole as write notices are. */ +function showLookupFailure(failure: LookupFailure): void { + toast.error( + agentSessionWriteNoticeText( + failure === 'older-host' + ? ['unsupported'] + : failure === 'update' + ? ['updateOrcaToOpenChat'] + : ['unreachable', 'tryAgain'] + ) + ) +} diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index a827d65296a2..3ce38f6057ce 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 } @@ -115,6 +116,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 /** Written on reasoning rows. ABSENT MEANS UNKNOWN — an older host, or a row from before the * field — and never reads as live. The row's `observedAt` is when it started. */ state?: AgentJournalMessageState 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..c4d3cd127908 --- /dev/null +++ b/src/shared/agent-session-message-source.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { + agentMessageSendersShown, + 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('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..d86d244df0a6 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,23 @@ 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 +/** Who a send is from, as its submission records it: the kind only (`AgentJournalSubmission.source`), + * so a restart or a close keeps only a person's unsent send as a card. The senders stay on the + * message body. */ +export type AgentSessionMessageSource = Readonly<{ kind: 'user' | 'agent' }> export const USER_MESSAGE_SOURCE: AgentSessionMessageSource = { kind: 'user' } -const MESSAGE_SOURCE_VERSION = 1 +export const AGENT_MESSAGE_SOURCE: AgentSessionMessageSource = { kind: 'agent' } + +/** 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 +66,43 @@ 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]) - }) -]) - -export function serializeAgentSessionMessageSource(source: AgentSessionMessageSource): string { - return JSON.stringify({ v: MESSAGE_SOURCE_VERSION, ...source }) +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 } -/** - * 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 46c82b7fabf9..eb17064c1e54 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -22,6 +22,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 } @@ -233,6 +234,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { completedAt?: number /** On a conversation command the user sent, such as `/compact`: the command it names. */ command?: { name: string } + /** 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..4bd1e3de62bb 100644 --- a/src/shared/orchestration-caller-status.ts +++ b/src/shared/orchestration-caller-status.ts @@ -25,3 +25,15 @@ 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`: a null location is a party this host does not find. `lost` says + * it proved that party gone, and what it was; absent, the host cannot tell (one it does not run). */ +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 47d3edd1c04c..cfbecf7bf7a7 100644 --- a/src/shared/rpc-contract/orchestration-params.ts +++ b/src/shared/rpc-contract/orchestration-params.ts @@ -111,6 +111,13 @@ export const SessionAddressParams = z.object({ sessionId: requiredString('Missing sessionId') }) +/** 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'), + messageIds: z.array(z.string().min(1).max(256)).max(50).optional() +}) + 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 7276915e1d93..0e1c72a2f64d 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, @@ -1023,6 +1024,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 6eee55bbeda9..1e7f6c0d5b25 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -36,6 +36,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' @@ -183,6 +184,8 @@ export function projectStructuredItemToNativeChat( const sentAs = item.body.kind === 'message' ? item.body.sentAs : undefined const lifecycle = item.body.kind === 'message' ? messageLifecycle(item.body) : {} const command = item.body.kind === 'message' ? item.body.command : 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), @@ -192,7 +195,8 @@ export function projectStructuredItemToNativeChat( // A send mode this build cannot name renders as an ordinary message. ...(sentAs !== undefined && isAgentJournalMessageSendMode(sentAs) ? { sentAs } : {}), ...lifecycle, - ...(command ? { command } : {}) + ...(command ? { command } : {}), + ...(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 } } diff --git a/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts index 86eed2b8271e..24b495575fbd 100644 --- a/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts @@ -139,8 +139,7 @@ test('an older build lists a kept card first and never sends it, even after a pe messageId: 'queued-card', body: message('queued-card'), fingerprint: 'fp-queued-card', - hostInstance: 'host-a', - source: USER_MESSAGE_SOURCE + hostInstance: 'host-a' }) await acceptPersonSend(earlier, 'kept') await journals.closeAll()