diff --git a/apps/desktop/e2e/session-mailbox.spec.ts b/apps/desktop/e2e/session-mailbox.spec.ts new file mode 100644 index 0000000000..187293e6ea --- /dev/null +++ b/apps/desktop/e2e/session-mailbox.spec.ts @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Page } from '@playwright/test'; +import { expect, test, COMPOSER_INPUT } from './fixtures'; + +async function establishSourceAndTarget( + page: Page, + targetName: string, +): Promise { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('建立发送方任务'); + await composer.press('Enter'); + await expect(page.getByText('Fake backend received: 建立发送方任务')).toBeVisible(); + return page.evaluate(async (name) => { + const source = (await window.maka.sessions.list())[0]; + if (!source) throw new Error('source Session was not created'); + const target = await window.maka.sessions.create({ + name, + cwd: source.cwd, + projectId: source.projectId, + }); + return target.id; + }, targetName); +} + +async function selectMailboxTarget( + page: Page, + query: string, + targetName: string, +): Promise { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('/send'); + await composer.press('Enter'); + const search = page.getByPlaceholder('搜索任务名称…'); + await expect(search).toBeVisible(); + await search.fill(query); + const target = page.getByRole('option', { name: new RegExp(targetName) }); + await expect(target).toBeVisible(); + await target.click(); +} + +test('selects a searchable /send target and settles delivery into the transcript', async ({ + window: page, +}, testInfo) => { + const composer = page.locator(COMPOSER_INPUT); + await establishSourceAndTarget(page, '支付回调恢复检查'); + await selectMailboxTarget(page, '恢复检查', '支付回调恢复检查'); + + await expect(page.getByText('发送给“支付回调恢复检查”')).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('session-mailbox-before-send.png') }); + await composer.fill('请检查回执恢复链路'); + await composer.press('Enter'); + + const card = page.locator('.maka-session-mailbox-bubble', { + hasText: '请检查回执恢复链路', + }); + await expect(card).toContainText('支付回调恢复检查'); + await expect(card).toContainText(/已送达|已排队/); + await expect(page.getByText('发送给“支付回调恢复检查”')).toHaveCount(0); + const dismissToast = page.getByRole('button', { name: '关闭通知' }); + if (await dismissToast.isVisible()) await dismissToast.click(); + await page.screenshot({ path: testInfo.outputPath('session-mailbox-card.png') }); +}); + +test('cancels a selected /send target without consuming the next message', async ({ + window: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + await establishSourceAndTarget(page, '可以取消的任务'); + await selectMailboxTarget(page, '可以取消', '可以取消的任务'); + + const notice = page.locator('[data-send-target-notice="true"]'); + await expect(notice).toContainText('发送给“可以取消的任务”'); + await notice.getByRole('button', { name: '取消' }).click(); + await expect(notice).toHaveCount(0); + + await composer.fill('取消后仍是普通消息'); + await composer.press('Enter'); + await expect(page.getByText('Fake backend received: 取消后仍是普通消息')).toBeVisible(); + await expect(page.locator('.maka-session-mailbox-bubble')).toHaveCount(0); +}); + +test('keeps failed /send delivery actionable after the target becomes unavailable', async ({ + window: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + const targetId = await establishSourceAndTarget(page, '即将归档的任务'); + await selectMailboxTarget(page, '即将归档', '即将归档的任务'); + await page.evaluate((sessionId) => window.maka.sessions.archive(sessionId), targetId); + + await composer.fill('这条发送应当失败'); + await composer.press('Enter'); + const notice = page.locator('[data-send-target-notice="true"]'); + await expect(notice).toHaveAttribute('data-delivery-status', 'failed'); + await expect(notice).toContainText('发送给“即将归档的任务”失败'); + await expect(composer).toHaveText('这条发送应当失败'); + await expect(page.locator('.maka-session-mailbox-bubble')).toHaveCount(0); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index 662bcf4f98..821f9ff1dd 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -467,6 +467,41 @@ test('desktop adapter projects Session catalog facts without owning copies', asy ]); }); +test('desktop adapter invalidates and re-reads a renamed Session even when activity time is unchanged', async () => { + let sessionName = 'Original name'; + let onChanged: (() => void) | undefined; + const adapter = createDesktopWorkHubSessionPort({ + transcripts: unusedTranscripts, + sessions: { + list: async () => [desktopSession('ordinary', { + name: sessionName, + lastMessageAt: 30, + })], + listTurns: async () => [], + create: async () => { throw new Error('not used'); }, + send: async () => { throw new Error('not used'); }, + stop: async () => {}, + subscribeChanges: (handler) => { + onChanged = handler; + return () => {}; + }, + }, + projectName: () => 'Maka', + newTurnId: () => 'unused', + }); + + assert.equal((await adapter.list())[0]?.sessionName, 'Original name'); + let invalidations = 0; + adapter.subscribe(() => { + invalidations += 1; + }); + sessionName = 'Renamed without a new message'; + onChanged?.(); + + assert.equal(invalidations, 1); + assert.equal((await adapter.list())[0]?.sessionName, 'Renamed without a new message'); +}); + test('desktop adapter preserves per-Host catalog coverage for ownership reconciliation', async () => { const localSessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'local' }); const adapter = createDesktopWorkHubSessionPort({ diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 4aa110e773..6066e0906b 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -24,6 +24,7 @@ import { RuntimeHostOperationError, RuntimeHostRequestInterruptedError, } from '@maka/runtime-host/client'; +import { HOST_OPERATION_SPECS } from '@maka/runtime-host/protocol'; import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token'; import { isSideConversationSession } from '@maka/core/side-conversation'; import { @@ -109,7 +110,9 @@ type RuntimeHostSessionExecutionClient = Pick< | "submitMessage" | "updateSessionMetadata" | "updateSessionConfiguration" ->; +> & { + request?: DesktopRuntimeHostClient['request']; +}; async function submitMessageWithReconnect( client: Pick, @@ -254,6 +257,38 @@ export function registerRuntimeHostSessionExecutionIpc( ], ); + handleReconnectableRead( + ipcMain, + 'sessions:mailboxTargets', + async (_event, sourceSessionId: unknown) => { + if (!deps.client.request) { + throw new Error('Session messaging is unavailable on this Runtime Host client'); + } + return deps.client.request('session.mailbox.targets', { + sourceSessionId: requiredId(sourceSessionId, 'Source Session'), + }); + }, + ); + + ipcMain.handle( + 'sessions:mailboxSend', + async (_event, sourceSessionId: unknown, targetSessionId: unknown, text: unknown) => { + if (!deps.client.request) { + throw new Error('Session messaging is unavailable on this Runtime Host client'); + } + return deps.client.request( + 'session.mailbox.send', + HOST_OPERATION_SPECS['session.mailbox.send'].decodeInput({ + sourceSessionId: requiredId(sourceSessionId, 'Source Session'), + targetSessionId: requiredId(targetSessionId, 'Target Session'), + messageId: newId(), + kind: 'request', + text, + }), + ); + }, + ); + ipcMain.handle( "sessions:send", async (event, sessionId: string, input: unknown) => { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index f2aa6d8817..3c2c34d469 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -783,6 +783,14 @@ export interface MakaBridge { completeHostIds: string[]; }>; create(input?: CreateSessionRequestInput): Promise; + listMailboxTargets( + sourceSessionId: string, + ): Promise; + sendMailboxMessage( + sourceSessionId: string, + targetSessionId: string, + text: string, + ): Promise; send( sessionId: string, command: diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 1671c2da6f..812296ed1c 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1585,6 +1585,37 @@ const makaBridge = { const scope = await activeRuntimeHostRef(); return createDesktopSessionOnScope(scope, input); }, + async listMailboxTargets(sourceSessionId: string) { + const source = await runtimeHostSessionRef(sourceSessionId); + const result = await ipcRenderer.invoke( + 'sessions:mailboxTargets', + source.scope, + source.sessionId, + ) as OperationOutput<'session.mailbox.targets'>; + return result.targets.map((target) => ({ + ...target, + sessionId: desktopSessionKey({ + hostId: source.scope.hostId, + sessionId: target.sessionId, + }), + })); + }, + async sendMailboxMessage(sourceSessionId: string, targetSessionId: string, text: string) { + const [source, target] = await Promise.all([ + runtimeHostSessionRef(sourceSessionId), + runtimeHostSessionRef(targetSessionId), + ]); + if (source.scope.hostId !== target.scope.hostId) { + throw new Error('Session messages require both tasks to use the same Runtime Host'); + } + return ipcRenderer.invoke( + 'sessions:mailboxSend', + source.scope, + source.sessionId, + target.sessionId, + text, + ); + }, async send( sessionId: string, command: diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index d398cbff8e..ba10134688 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -69,7 +69,7 @@ import { reconcileInteractions, } from '@maka/ui'; import type { ConnectionEvent } from '@maka/core/connections'; -import { GitBranch, MessageCircleQuestion, Minimize2, Network } from '@maka/ui/icons'; +import { GitBranch, MessageCircleQuestion, Minimize2, Network, Pencil } from '@maka/ui/icons'; import { Button } from '@astryxdesign/core/Button'; import { useKeyboardHelp } from './keyboard-help'; import { useCommandPalette } from './command-palette'; @@ -100,6 +100,7 @@ import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from './new-task-reload-intent'; import { useNewTaskChoice } from './use-new-task-choice'; import { NEW_TASK_PENDING_KEY } from './pending-items'; import { parseDesktopSlashCommand } from './desktop-slash-command'; +import { useAppShellMailbox } from './use-app-shell-mailbox'; import { hasActiveTurnAtSubmit, mergeWorkspaceReferences, @@ -758,6 +759,16 @@ function AppShellContent({ revisionDraftRef.current = draft; setRevisionDraft(draft); }, []); + const mailbox = useAppShellMailbox({ + activeSessionId: activeId, + uiLocale, + revisionActive: revisionDraft !== null, + hasPendingComposerContext: pendingAttachments.length > 0 || pendingQuotes.length > 0, + composerRef, + setMessages, + toastInfo: toastApi.info, + showSessionError, + }); useEffect(() => { const draft = revisionDraftRef.current; if (!draft) return; @@ -1378,6 +1389,7 @@ function AppShellContent({ onRetry: () => reloadActiveExecutionBoundary(activeId), } : undefined; + const openMailboxTargetPicker = mailbox.openTargetPicker; const desktopSlashCommands = useMemo( () => { const streaming = turnActive || activeStreamingLive; @@ -1395,6 +1407,17 @@ function AppShellContent({ keywords: ['compact', 'context', '压缩', '上下文'], Icon: Minimize2, }, + rename: { + ...shellCopy.slashCommands.rename, + keywords: ['rename', 'name', 'title', '重命名', '名称'], + Icon: Pencil, + }, + send: { + ...shellCopy.slashCommands.send, + keywords: ['send', 'session', 'message', '发送', '任务', '会话'], + Icon: MessageCircleQuestion, + onChoose: () => { void openMailboxTargetPicker(); }, + }, side: { ...shellCopy.slashCommands.side, keywords: ['side', 'btw', '侧聊', '追问'], @@ -1413,7 +1436,7 @@ function AppShellContent({ }; return availableCommands.map(({ id }) => ({ id, ...presentation[id] })); }, - [activeId, activeStreamingLive, shellCopy.slashCommands, turnActive], + [activeId, activeStreamingLive, openMailboxTargetPicker, shellCopy.slashCommands, turnActive], ); const refreshProjectSkillsRef = useRef<() => Promise>(async () => {}); const { @@ -1649,7 +1672,7 @@ function AppShellContent({ newSessionPermissionMode: newTaskPermissionMode, }); - const hasModalOpen = helpOpen || paletteOpen || searchModalOpen; + const hasModalOpen = helpOpen || paletteOpen || searchModalOpen || mailbox.modalOpen; const shellObscured = hasModalOpen || settingsOpen; const contextCompactionPresentation = useMemo( () => @@ -1852,6 +1875,11 @@ function AppShellContent({ text: string, metadata?: ComposerSendMetadata, ): Promise { + const mailboxResult = await mailbox.sendPending( + text, + (metadata?.workspaceFileReferences?.length ?? 0) > 0, + ); + if (mailboxResult !== undefined) return mailboxResult; setNewTaskSendPending(true); try { return await sendWithAttachments(text, metadata); @@ -1988,6 +2016,41 @@ function AppShellContent({ return false; } } + if (slashCommand?.kind === 'rename') { + const sessionId = activeIdRef.current; + if (!sessionId) return false; + try { + await window.maka.sessions.rename(sessionId, slashCommand.name, { + revisionFamily: true, + }); + await refreshSessions(); + toastApi.success( + shellCopy.renameSuccessTitle, + shellCopy.renameSuccessDescription(slashCommand.name), + ); + return true; + } catch (error) { + showSessionError( + sessionId, + getShellCopy(uiLocale).sessionRowActions.renameFailedTitle, + localizedShellErrorMessage(error, shellCopy.tryAgainLater, uiLocale), + ); + return false; + } + } + if (slashCommand?.kind === 'rename_invalid') { + toastApi.info(shellCopy.renameUsageTitle, shellCopy.renameUsageDescription); + return false; + } + if (slashCommand?.kind === 'send') { + return mailbox.openTargetPicker( + (metadata?.workspaceFileReferences?.length ?? 0) > 0, + ); + } + if (slashCommand?.kind === 'send_invalid') { + toastApi.info(shellCopy.mailboxUsageTitle, shellCopy.mailboxUsageDescription); + return false; + } if (slashCommand?.kind === 'side') { if (!activeIdRef.current) { toastApi.info( @@ -2896,6 +2959,7 @@ function AppShellContent({ } : undefined } + sendTargetNotice={mailbox.sendTargetNotice} mentionSkills={mentionSkills} mentionSkillsUnavailable={mentionSkillsUnavailable} mentionSkillsLoading={mentionSkillsLoading} @@ -3168,6 +3232,8 @@ function AppShellContent({ + {mailbox.picker} + ; + mailboxComposerTitle(targetName: string): string; + mailboxComposerDescription: string; + mailboxComposerCancel: string; + mailboxSendingTitle(targetName: string): string; + mailboxSendingDescription: string; + mailboxDeliveredReceiptTitle(targetName: string): string; + mailboxDeliveredReceiptDescription: string; + mailboxQueuedReceiptTitle(targetName: string): string; + mailboxQueuedReceiptDescription: string; + mailboxFailedReceiptTitle(targetName: string): string; + mailboxFailedReceiptDescription: string; + mailboxReceiptDismiss: string; + renameUsageTitle: string; + renameUsageDescription: string; + renameSuccessTitle: string; + renameSuccessDescription(name: string): string; resumeStartedTitle: string; resumeStartedDescription: string; resumeFailedTitle: string; @@ -1192,6 +1220,8 @@ const SHELL_COPY_BY_LOCALE = { slashCommands: { compact: { name: '压缩上下文', description: '压缩旧历史并保留当前任务' }, graph: { name: '使用 Graph', description: '查看、切换或单次运行 Graph' }, + rename: { name: '重命名当前任务', description: '/rename <新名称>' }, + send: { name: '发送到其他任务', description: '先选择接收任务,再输入消息' }, side: { name: '打开侧聊', description: '在右侧开始一个具体话题' }, swarm: { name: '使用 Swarm', description: '查看、切换或单次运行 Swarm' }, }, @@ -1200,6 +1230,38 @@ const SHELL_COPY_BY_LOCALE = { sideChatContextPendingTitle: '先处理待发送的上下文', sideChatContextPendingDescription: '当前 Composer 还有附件、引用或文件 mention。请先发送或移除它们,再使用 /side。', + mailboxFailedTitle: '发送失败', + mailboxFailedFallback: '无法向目标任务发送消息。', + mailboxUsageTitle: '命令格式不完整', + mailboxUsageDescription: '请输入 /send;选择接收任务后再填写消息。', + mailboxPickerTitle: '发送到哪个任务?', + mailboxPickerPlaceholder: '搜索任务名称…', + mailboxPickerSearchLabel: '搜索可接收消息的任务', + mailboxPickerResultsLabel: '可达任务', + mailboxPickerEmpty: '当前项目中没有其他可接收消息的任务。', + mailboxPickerNoMatch: '没有匹配的任务。', + mailboxPickerHint: '使用自定义任务名称;未重命名时显示系统生成的名称', + mailboxPickerStatus: { + idle: '空闲 · 立即送达', + running: '运行中 · 排入下一轮', + waiting_for_user: '等待输入 · 排入下一轮', + }, + mailboxComposerTitle: (targetName: string) => `发送给“${targetName}”`, + mailboxComposerDescription: '在原输入框填写消息,按 Enter 发送;按 Esc 取消。', + mailboxComposerCancel: '取消', + mailboxSendingTitle: (targetName: string) => `正在发送给“${targetName}”…`, + mailboxSendingDescription: '正在等待 Runtime Host 确认。', + mailboxDeliveredReceiptTitle: (targetName: string) => `已送达“${targetName}”`, + mailboxDeliveredReceiptDescription: '目标任务空闲,消息已立即进入处理。', + mailboxQueuedReceiptTitle: (targetName: string) => `已排队给“${targetName}”`, + mailboxQueuedReceiptDescription: '目标任务正在运行,消息将在下一轮处理。', + mailboxFailedReceiptTitle: (targetName: string) => `发送给“${targetName}”失败`, + mailboxFailedReceiptDescription: '消息仍保留在输入框中;按 Enter 重试,或按 Esc 取消。', + mailboxReceiptDismiss: '知道了', + renameUsageTitle: '命令格式不完整', + renameUsageDescription: '请输入 /rename <新名称>。也可以点击窗口顶部的任务名称重命名。', + renameSuccessTitle: '任务已重命名', + renameSuccessDescription: (name: string) => `新名称:${name}`, resumeStartedTitle: '已开始安全恢复', resumeStartedDescription: '正在从最后一个完整执行边界继续', resumeFailedTitle: '恢复失败', @@ -1757,6 +1819,8 @@ const SHELL_COPY_BY_LOCALE = { slashCommands: { compact: { name: 'Compact context', description: 'Compact older history while preserving the current task' }, graph: { name: 'Use Graph', description: 'Inspect, switch, or run Graph once' }, + rename: { name: 'Rename current task', description: '/rename ' }, + send: { name: 'Send to another task', description: 'Choose the receiving task, then write the message' }, side: { name: 'Open side chat', description: 'Start a specific topic in the side panel' }, swarm: { name: 'Use Swarm', description: 'Inspect, switch, or run Swarm once' }, }, @@ -1766,6 +1830,38 @@ const SHELL_COPY_BY_LOCALE = { sideChatContextPendingTitle: 'Resolve pending context first', sideChatContextPendingDescription: 'The Composer still has attachments, quotes, or file mentions. Send or remove them before using /side.', + mailboxFailedTitle: 'Could not send message', + mailboxFailedFallback: 'The message could not be sent to the target task.', + mailboxUsageTitle: 'Incomplete command', + mailboxUsageDescription: 'Enter /send; choose the receiving task, then write the message.', + mailboxPickerTitle: 'Send to which task?', + mailboxPickerPlaceholder: 'Search task names…', + mailboxPickerSearchLabel: 'Search tasks that can receive this message', + mailboxPickerResultsLabel: 'Reachable tasks', + mailboxPickerEmpty: 'There are no other reachable tasks in this project.', + mailboxPickerNoMatch: 'No matching task.', + mailboxPickerHint: 'Shows custom task names, or the system-generated name when unchanged', + mailboxPickerStatus: { + idle: 'Idle · deliver now', + running: 'Running · queue next', + waiting_for_user: 'Waiting · queue next', + }, + mailboxComposerTitle: (targetName: string) => `Send to “${targetName}”`, + mailboxComposerDescription: 'Write in the composer and press Enter to send; press Esc to cancel.', + mailboxComposerCancel: 'Cancel', + mailboxSendingTitle: (targetName: string) => `Sending to “${targetName}”…`, + mailboxSendingDescription: 'Waiting for confirmation from the Runtime Host.', + mailboxDeliveredReceiptTitle: (targetName: string) => `Delivered to “${targetName}”`, + mailboxDeliveredReceiptDescription: 'The target was idle, so the message started immediately.', + mailboxQueuedReceiptTitle: (targetName: string) => `Queued for “${targetName}”`, + mailboxQueuedReceiptDescription: 'The target is running and will process it on the next turn.', + mailboxFailedReceiptTitle: (targetName: string) => `Could not send to “${targetName}”`, + mailboxFailedReceiptDescription: 'The message remains in the composer; press Enter to retry or Esc to cancel.', + mailboxReceiptDismiss: 'Dismiss', + renameUsageTitle: 'Incomplete command', + renameUsageDescription: 'Enter /rename . You can also click the task name in the title bar.', + renameSuccessTitle: 'Task renamed', + renameSuccessDescription: (name: string) => `New name: ${name}`, resumeStartedTitle: 'Safe recovery started', resumeStartedDescription: 'Continuing from the last complete execution boundary', resumeFailedTitle: 'Recovery failed', diff --git a/apps/desktop/src/renderer/session-mailbox-picker.tsx b/apps/desktop/src/renderer/session-mailbox-picker.tsx new file mode 100644 index 0000000000..40b486a4b3 --- /dev/null +++ b/apps/desktop/src/renderer/session-mailbox-picker.tsx @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useMemo, useRef } from 'react'; +import type { SessionMailboxTarget } from '@maka/runtime-host/protocol'; +import { + AstryxLocaleProvider, + CommandPalette, + CommandPaletteFooter, + CommandPaletteInput, + type SearchableItem, + type SearchSource, + useUiLocale, +} from '@maka/ui'; +import { getShellCopy } from './locales/shell-copy'; + +type TargetItem = SearchableItem<{ target: SessionMailboxTarget }>; + +export function SessionMailboxPicker(props: { + targets: readonly SessionMailboxTarget[]; + onOpenChange(open: boolean): void; + onSelect(target: SessionMailboxTarget): void; +}) { + const copy = getShellCopy(useUiLocale()).app; + const pendingTargetRef = useRef(undefined); + const items = useMemo( + () => + props.targets.map((target) => ({ + id: target.sessionId, + label: target.name, + auxiliaryData: { target }, + })), + [props.targets], + ); + const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]); + const searchSource = useMemo>( + () => ({ + bootstrap: () => items, + search: (query) => { + const normalized = query.trim().toLowerCase(); + if (!normalized) return items; + return items.filter((item) => item.label.toLowerCase().includes(normalized)); + }, + }), + [items], + ); + + const close = (open: boolean) => { + props.onOpenChange(open); + if (open) return; + const target = pendingTargetRef.current; + pendingTargetRef.current = undefined; + if (target) window.requestAnimationFrame(() => props.onSelect(target)); + }; + + return ( + + + )} + emptyBootstrapText={copy.mailboxPickerEmpty} + emptySearchText={copy.mailboxPickerNoMatch} + onValueChange={(itemId) => { + const target = itemById.get(itemId)?.auxiliaryData?.target; + if (!target || pendingTargetRef.current) return; + pendingTargetRef.current = target; + close(false); + }} + renderItem={(item) => { + const target = item.auxiliaryData?.target; + if (!target) return item.label; + return ( + <> + {target.name} + + {copy.mailboxPickerStatus[target.status]} + + + ); + }} + footer={( + {copy.mailboxPickerHint} + )} + /> + + ); +} diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 3df4aa0064..70db39168c 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -101,6 +101,69 @@ white-space: pre-wrap; } +/* Cross-task messages are transcript objects, not decorated prompt text. The + source/destination is persistent card chrome while the body stays the only + copyable message content. */ +.maka-session-mailbox-bubble { + min-width: min(360px, 72vw); + max-width: min(620px, 78vw); + overflow: hidden; + padding: 0; + border: var(--border-width-hairline) solid var(--border); + border-radius: var(--radius-surface); + background: var(--surface-raised); + box-shadow: none; +} + +.maka-session-mailbox-header { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-1-5); + padding: var(--space-2) var(--space-3); + border-bottom: var(--border-width-hairline) solid var(--info-wash-border); + color: var(--muted-foreground); + background: var(--info-wash); + white-space: nowrap; +} + +.maka-session-mailbox-header[data-direction='outgoing'] { + border-bottom-color: var(--success-wash-border); + background: var(--success-wash); +} + +.maka-session-mailbox-eyebrow { + flex: 0 0 auto; + font: var(--maka-text-meta); +} + +.maka-session-mailbox-session-name { + min-width: 0; + overflow: hidden; + color: var(--foreground); + font: var(--maka-text-supporting); + font-weight: var(--font-weight-semibold); + text-overflow: ellipsis; +} + +.maka-session-mailbox-disposition { + flex: 0 0 auto; + margin-left: auto; + color: var(--muted-foreground); + font: var(--maka-text-meta); +} + +.maka-session-mailbox-body { + padding: var(--space-2-5) var(--space-3) var(--space-2); + color: var(--foreground); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.maka-session-mailbox-bubble .maka-message-meta { + padding: 0 var(--space-3) var(--space-2); +} + .maka-chat-message-bubble-assistant { width: 100%; max-width: none; diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index 43c4f76250..a3abd0f040 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -487,6 +487,28 @@ opacity: var(--opacity-disabled); } +/* Cross-Session routing is a durable delivery state, not a transient editor + hint. Give it card geometry so "sending / delivered / queued / failed" stays + legible after the input itself has cleared. */ +.maka-composer-mailbox-notice { + min-height: var(--h-control-lg); + height: auto; + padding: var(--space-2) var(--space-2-5); + border-radius: var(--radius-surface); + background: var(--info-wash); + border-color: var(--info-wash-border); + color: var(--foreground); +} + +.maka-composer-mailbox-notice[data-delivery-status='failed'] { + background: var(--destructive-wash); + border-color: var(--destructive-wash-border); +} + +.maka-composer-mailbox-notice[data-delivery-status='failed'] > svg { + color: var(--destructive-text); +} + /* The project picker, last in the composer footer's send-context group. It is rendered only while no session owns the composer — the project is a session-creation parameter. See packages/ui/src/workspace-picker.tsx. */ diff --git a/apps/desktop/src/renderer/use-app-shell-mailbox.tsx b/apps/desktop/src/renderer/use-app-shell-mailbox.tsx new file mode 100644 index 0000000000..5275acb482 --- /dev/null +++ b/apps/desktop/src/renderer/use-app-shell-mailbox.tsx @@ -0,0 +1,258 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type RefObject, + type SetStateAction, +} from 'react'; +import type { StoredMessage } from '@maka/core/session'; +import { sessionMailboxSentReceiptId } from '@maka/core/session-mailbox'; +import type { UiLocale } from '@maka/core/ui-locale'; +import type { SessionMailboxTarget } from '@maka/runtime-host/protocol'; +import type { ComposerHandle } from '@maka/ui'; +import { SessionMailboxPicker } from './session-mailbox-picker'; +import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy'; +import { getDesktopConversationCopy } from './locales/conversation-copy'; + +interface AppShellMailboxInput { + readonly activeSessionId?: string; + readonly uiLocale: UiLocale; + readonly revisionActive: boolean; + readonly hasPendingComposerContext: boolean; + readonly composerRef: RefObject; + readonly setMessages: Dispatch>; + readonly toastInfo: (title: string, description?: string) => void; + readonly showSessionError: (sessionId: string, title: string, description?: string) => void; +} + +interface PendingMailboxTarget { + readonly sourceSessionId: string; + readonly target: SessionMailboxTarget; +} + +export function useAppShellMailbox(input: AppShellMailboxInput) { + const { + activeSessionId, + uiLocale, + revisionActive, + hasPendingComposerContext, + composerRef, + setMessages, + toastInfo, + showSessionError, + } = input; + const copy = useMemo(() => getShellCopy(uiLocale).app, [uiLocale]); + const conversationActions = useMemo( + () => getDesktopConversationCopy(uiLocale).actions, + [uiLocale], + ); + const activeSessionIdRef = useRef(activeSessionId); + activeSessionIdRef.current = activeSessionId; + const [pickerFlow, setPickerFlow] = useState<{ + sourceSessionId: string; + targets: readonly SessionMailboxTarget[]; + } | null>(null); + const [pendingTarget, setPendingTarget] = useState(null); + const [deliveryFeedback, setDeliveryFeedback] = useState< + (PendingMailboxTarget & { status: 'sending' | 'failed' }) | null + >(null); + + useEffect(() => { + if (pendingTarget && pendingTarget.sourceSessionId !== activeSessionId) { + setPendingTarget(null); + setDeliveryFeedback(null); + } + if (deliveryFeedback && deliveryFeedback.sourceSessionId !== activeSessionId) { + setDeliveryFeedback(null); + } + if (pickerFlow && pickerFlow.sourceSessionId !== activeSessionId) { + setPickerFlow(null); + } + }, [activeSessionId, deliveryFeedback, pendingTarget, pickerFlow]); + + const openTargetPicker = useCallback(async (hasWorkspaceReferences = false) => { + const sourceSessionId = activeSessionIdRef.current; + if (!sourceSessionId) return false; + if (revisionActive) { + toastInfo( + conversationActions.revisionUnavailableTitle, + conversationActions.revisionCommandUnsupported, + ); + return false; + } + if (hasPendingComposerContext || hasWorkspaceReferences) { + toastInfo(copy.sideChatContextPendingTitle, copy.sideChatContextPendingDescription); + return false; + } + try { + const targets = await window.maka.sessions.listMailboxTargets(sourceSessionId); + setPendingTarget(null); + setDeliveryFeedback(null); + setPickerFlow({ sourceSessionId, targets }); + return true; + } catch (error) { + showSessionError( + sourceSessionId, + copy.mailboxFailedTitle, + localizedShellErrorMessage(error, copy.mailboxFailedFallback, uiLocale), + ); + return false; + } + }, [ + conversationActions, + copy, + hasPendingComposerContext, + revisionActive, + showSessionError, + toastInfo, + uiLocale, + ]); + + const sendPending = useCallback(async ( + text: string, + hasWorkspaceReferences = false, + ): Promise => { + if (!pendingTarget) return undefined; + if (activeSessionIdRef.current !== pendingTarget.sourceSessionId) { + setPendingTarget(null); + setDeliveryFeedback(null); + return false; + } + if (hasPendingComposerContext || hasWorkspaceReferences) { + toastInfo(copy.sideChatContextPendingTitle, copy.sideChatContextPendingDescription); + return false; + } + + setDeliveryFeedback({ ...pendingTarget, status: 'sending' }); + try { + const result = await window.maka.sessions.sendMailboxMessage( + pendingTarget.sourceSessionId, + pendingTarget.target.sessionId, + text, + ); + setPendingTarget(null); + setDeliveryFeedback(null); + const receiptId = sessionMailboxSentReceiptId(result.messageId); + if (activeSessionIdRef.current === pendingTarget.sourceSessionId) { + setMessages((current) => { + if (current.some((message) => message.id === receiptId)) return current; + let anchorTurnId: string | undefined; + for (let index = current.length - 1; index >= 0; index -= 1) { + anchorTurnId = current[index]?.turnId; + if (anchorTurnId) break; + } + return [...current, { + type: 'system_note', + id: receiptId, + ...(anchorTurnId ? { turnId: anchorTurnId } : {}), + ts: Date.now(), + kind: 'session_mailbox_sent', + data: { + messageId: result.messageId, + targetSessionId: pendingTarget.target.sessionId, + targetSessionName: pendingTarget.target.name, + kind: 'request', + text, + disposition: result.disposition, + ...(result.turnId ? { turnId: result.turnId } : {}), + }, + }]; + }); + } + return true; + } catch (error) { + setDeliveryFeedback({ ...pendingTarget, status: 'failed' }); + showSessionError( + pendingTarget.sourceSessionId, + copy.mailboxFailedTitle, + localizedShellErrorMessage(error, copy.mailboxFailedFallback, uiLocale), + ); + return false; + } + }, [ + copy, + hasPendingComposerContext, + pendingTarget, + setMessages, + showSessionError, + toastInfo, + uiLocale, + ]); + + const sendTargetNotice = useMemo(() => { + if (deliveryFeedback && deliveryFeedback.sourceSessionId === activeSessionId) { + const dismiss = () => { + setDeliveryFeedback(null); + if (deliveryFeedback.status === 'failed') setPendingTarget(null); + }; + return deliveryFeedback.status === 'sending' + ? { + title: copy.mailboxSendingTitle(deliveryFeedback.target.name), + detail: copy.mailboxSendingDescription, + cancelLabel: copy.mailboxComposerCancel, + status: 'sending' as const, + onCancel: dismiss, + } + : { + title: copy.mailboxFailedReceiptTitle(deliveryFeedback.target.name), + detail: copy.mailboxFailedReceiptDescription, + cancelLabel: copy.mailboxComposerCancel, + status: 'failed' as const, + onCancel: dismiss, + }; + } + if (!pendingTarget || pendingTarget.sourceSessionId !== activeSessionId) return undefined; + return { + title: copy.mailboxComposerTitle(pendingTarget.target.name), + detail: copy.mailboxComposerDescription, + cancelLabel: copy.mailboxComposerCancel, + status: 'ready' as const, + onCancel: () => setPendingTarget(null), + }; + }, [activeSessionId, copy, deliveryFeedback, pendingTarget]); + + const picker = pickerFlow ? ( + { + if (!open) setPickerFlow(null); + }} + onSelect={(target) => { + setDeliveryFeedback(null); + setPendingTarget({ sourceSessionId: pickerFlow.sourceSessionId, target }); + setPickerFlow(null); + window.requestAnimationFrame(() => composerRef.current?.focus()); + }} + /> + ) : null; + + return { + openTargetPicker, + sendPending, + sendTargetNotice, + picker, + modalOpen: pickerFlow !== null, + } as const; +} diff --git a/docs/images/pr/session-mailbox-after-send.png b/docs/images/pr/session-mailbox-after-send.png new file mode 100644 index 0000000000..5d33b78e33 Binary files /dev/null and b/docs/images/pr/session-mailbox-after-send.png differ diff --git a/docs/images/pr/session-mailbox-before-send.png b/docs/images/pr/session-mailbox-before-send.png new file mode 100644 index 0000000000..c4f6378da7 Binary files /dev/null and b/docs/images/pr/session-mailbox-before-send.png differ diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index dd378337aa..ac333cd741 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -39,7 +39,11 @@ import { type SessionSummary, type StoredMessage } from '@maka/core/session'; import { type ThinkingLevel } from '@maka/core/model-thinking'; import { type UserQuestionResponse } from '@maka/core/user-question'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; -import type { AgentGraphClientSnapshot } from '@maka/runtime-host/protocol'; +import type { + AgentGraphClientSnapshot, + SessionMailboxSendResult, + SessionMailboxTarget, +} from '@maka/runtime-host/protocol'; import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle'; import { type ContextDiagnostics } from '@maka/runtime/context-diagnostics'; import type { GoalProjection } from '@maka/runtime-host/protocol'; @@ -3032,6 +3036,57 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('/send opens a searchable Session picker and sends the next composed message', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + driver.mailboxTargets.push( + { sessionId: 'session-alpha', name: 'Alpha task', status: 'idle' }, + { sessionId: 'session-needle', name: 'Needle review task', status: 'running' }, + ); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'gpt-5.5', + connectionSlug: 'openai', + permissionMode: 'ask', + terminal, + }); + + await waitForTuiPaint(terminal); + terminal.input('/send'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Send to Session')); + await waitFor(() => { + const out = plainTerminalOutput(terminal.screenOutput()); + return out.includes('Alpha task') && out.includes('Needle review task'); + }); + + terminal.input('needle'); + await waitFor(() => { + const out = plainTerminalOutput(terminal.screenOutput()); + return out.includes('Needle review task') && !out.includes('Alpha task'); + }); + terminal.input('\r'); + terminal.input('Please inspect the recovery path'); + terminal.input('\r'); + await waitFor(() => driver.mailboxSends.length === 1); + assert.deepEqual(driver.mailboxSends, [ + { + targetSessionId: 'session-needle', + text: 'Please inspect the recovery path', + }, + ]); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + test('switches models in a fresh conversation without a cache warning', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); @@ -7464,6 +7519,8 @@ class SlashCommandDriver implements MakaSessionDriver { readonly sessionSwitchOptions: Array = []; readonly renames: string[] = []; readonly moves: string[] = []; + readonly mailboxTargets: SessionMailboxTarget[] = []; + readonly mailboxSends: Array<{ targetSessionId: string; text: string }> = []; startNewSessionCalls = 0; resumeCalls = 0; contextDiagnosticsRequests = 0; @@ -7620,6 +7677,21 @@ class SlashCommandDriver implements MakaSessionDriver { oldCwdDirty: true, }; } + async listMailboxTargets(): Promise { + return this.mailboxTargets; + } + async sendMailboxMessage( + targetSessionId: string, + text: string, + ): Promise { + this.mailboxSends.push({ targetSessionId, text }); + return { + messageId: `mailbox-${this.mailboxSends.length}`, + targetSessionId, + disposition: 'turn_started', + turnId: `mailbox-turn-${this.mailboxSends.length}`, + }; + } async setPermissionMode(mode: PermissionMode): Promise { this.permissionModes.push(mode); this.activeBoundaryDisplayMode = mode; diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 18dae33449..de20b8b17a 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -750,6 +750,108 @@ export class ModelSearchOverlay implements Component { } } +export interface SearchableSelectOverlayInput { + readonly title: string; + readonly rightLabel: string; + readonly items: readonly SelectItem[]; + readonly hint?: string; + readonly searchLabel?: string; + readonly emptyLabel?: string; + readonly onSelect: (item: SelectItem) => void; + readonly onCancel: () => void; +} + +/** A compact searchable single-select used by command pickers such as `/send`. */ +export class SearchableSelectOverlay implements Component { + private readonly searchEditor: Editor; + private filtered: readonly SelectItem[]; + private list: SelectList; + + constructor( + tui: TUI, + private readonly input: SearchableSelectOverlayInput, + ) { + this.filtered = input.items; + this.list = this.buildList(); + this.searchEditor = new Editor(tui, editorTheme(), { paddingX: 0 }); + this.searchEditor.onChange = (text) => this.applyQuery(text); + } + + private buildList(): SelectList { + const list = new SelectList([...this.filtered], 10, selectListTheme(), { + minPrimaryColumnWidth: 24, + maxPrimaryColumnWidth: 56, + }); + list.onSelect = (selected) => this.input.onSelect(selected); + list.onCancel = () => this.input.onCancel(); + return list; + } + + private applyQuery(text: string): void { + const query = text.trim().toLocaleLowerCase(); + this.filtered = query + ? this.input.items.filter((item) => + [item.label, item.description, item.value] + .filter((value): value is string => typeof value === 'string') + .some((value) => value.toLocaleLowerCase().includes(query)), + ) + : this.input.items; + this.list = this.buildList(); + } + + invalidate(): void { + this.searchEditor.invalidate(); + this.list.invalidate(); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { + this.input.onCancel(); + return; + } + if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) { + this.list.handleInput(data); + return; + } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) { + if (!isKeyRepeat(data) && this.filtered.length > 0) this.list.handleInput(data); + return; + } + this.searchEditor.handleInput(data); + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + const searchLabel = this.input.searchLabel ?? '搜索'; + this.searchEditor.focused = true; + return [ + padLine( + `${this.input.title} ${ansi.accent(this.input.rightLabel)} ${ansi.dim(String(this.filtered.length))}`, + safeWidth, + ), + padLine(this.input.hint ?? '输入搜索 · ↑↓ 选择 · Enter 确认 · Esc 取消', safeWidth), + padLine('', safeWidth), + ...this.renderFieldRow(this.searchEditor, searchLabel, safeWidth), + padLine('', safeWidth), + ...(this.filtered.length === 0 + ? [padLine(ansi.dim(this.input.emptyLabel ?? '没有匹配的会话'), safeWidth)] + : this.list.render(safeWidth).map((line) => formatPickerItemLine(line, safeWidth))), + padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth), + ]; + } + + private renderFieldRow(editor: Editor, label: string, width: number): string[] { + const prefix = `${label} `; + const prefixWidth = visibleWidth(prefix); + const editorLines = editor.render(Math.max(1, width - prefixWidth)).slice(1, -1); + return editorLines.length === 0 + ? [padLine(prefix, width)] + : editorLines.map((line, index) => + padLine(`${index === 0 ? prefix : ' '.repeat(prefixWidth)}${line}`, width), + ); + } +} + /** * #1611: `current` marks an option that is genuinely in force, so choosing it * is a no-op. A read-only session is neither of these options, and marking diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 6a44cc4bb6..4506b81c50 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -73,7 +73,11 @@ import type { } from './pi-tui-contracts.js'; import { AUTO_RECAP_DISPLAY_LIMIT_BYTES, shouldAutoRecap } from './session-recap.js'; import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; -import type { AgentGraphClientSnapshot, AgentGraphEpochSummary } from '@maka/runtime-host/protocol'; +import type { + AgentGraphClientSnapshot, + AgentGraphEpochSummary, + SessionMailboxTarget, +} from '@maka/runtime-host/protocol'; import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; import { MakaSkillHighlightEditor } from './skill-highlight-editor.js'; import { parseGraphCommand, type ParsedGraphCommand } from '@maka/core/graph-command'; @@ -130,6 +134,7 @@ import { ModelSearchOverlay, OnboardingWizard, PickerOverlay, + SearchableSelectOverlay, UserQuestionOverlay, modelPickerItems, permissionModePickerItems, @@ -368,6 +373,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // True while the /session picker is open mid-turn: Escape must close the // overlay, not arm the double-Escape interrupt for the running Turn (#3380). let sessionPickerOverlayOpen = false; + let pendingMailboxTarget: SessionMailboxTarget | undefined; let lastTurnEscapeAt = 0; let lastIdleEscapeAt = 0; let lastIdleCtrlCAt = 0; @@ -1101,6 +1107,32 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { let wizardAttempt = 0; editor.onSubmit = (prompt) => { + const mailboxTarget = pendingMailboxTarget; + if (mailboxTarget && prompt.trim()) { + pendingMailboxTarget = undefined; + editor.addToHistory(prompt); + state.entries.push({ + kind: 'notice', + level: 'info', + text: `Sending message to ${mailboxTarget.name}…`, + }); + requestRender(); + void input.driver + .sendMailboxMessage?.(mailboxTarget.sessionId, prompt.trim()) + .then((result) => { + state.entries.push({ + kind: 'notice', + level: 'info', + text: + result.disposition === 'queued' + ? `Message queued for ${mailboxTarget.name}.` + : `Message delivered to ${mailboxTarget.name}.`, + }); + requestRender(); + }) + .catch(reportError); + return; + } if (turnRunning) { // A quit/exit form typed while a turn is running must close the TUI, not // steer it into the model as prompt text (review finding on turnRunning @@ -3338,6 +3370,71 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { void goToSession(sessionId); }, }, + send: { + description: primaryGuidance.commands.send, + // This targets another Session and never mutates or steers the active + // Turn in the current Session, so it remains safe while that Turn runs. + midTurn: 'local', + run: (parts: string[]) => { + if (parts.length !== 1) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: 'Usage: /send', + }); + requestRender(); + return; + } + if (!input.driver.listMailboxTargets || !input.driver.sendMailboxMessage) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: 'Session messaging is unavailable on this runtime.', + }); + requestRender(); + return; + } + void input.driver + .listMailboxTargets() + .then((targets) => { + if (targets.length === 0) { + state.entries.push({ + kind: 'notice', + level: 'info', + text: 'No other reachable Sessions in this project.', + }); + requestRender(); + return; + } + const items = targets.map((target) => ({ + value: target.sessionId, + label: target.name, + description: + target.status === 'idle' + ? 'idle · deliver now' + : `${target.status.replaceAll('_', ' ')} · queue for next turn`, + })); + let overlay: OverlayHandle | undefined; + const picker = new SearchableSelectOverlay(tui, { + title: 'Send to Session', + rightLabel: 'Choose Session', + items, + hint: 'type to search · ↑↓ move · Enter choose · Esc cancel', + onSelect: (item) => { + overlay?.hide(); + const target = targets.find((candidate) => candidate.sessionId === item.value); + if (!target) return; + pendingMailboxTarget = target; + editor.setText(''); + requestRender(); + }, + onCancel: () => overlay?.hide(), + }); + overlay = showBottomPicker(picker); + }) + .catch(reportError); + }, + }, side: { description: primaryGuidance.commands.side, midTurn: 'switch', @@ -3454,6 +3551,17 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return { consume: true }; } if (tui.hasOverlay()) return undefined; + if (pendingMailboxTarget && matchesKey(data, Key.escape) && editor.getText().length === 0) { + const targetName = pendingMailboxTarget.name; + pendingMailboxTarget = undefined; + state.entries.push({ + kind: 'notice', + level: 'info', + text: `Cancelled message to ${targetName}.`, + }); + requestRender(); + return { consume: true }; + } const pendingSandboxBoundary = activeSandboxBoundaryRequest(state); if (pendingSandboxBoundary && !matchesKey(data, Key.ctrl('c'))) { if ( diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 99c7bee950..914b434642 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -287,6 +287,22 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { .map(({ session }) => session); } + async listMailboxTargets() { + const sourceSessionId = this.#requireSession('list Session message targets'); + return (await this.#request('session.mailbox.targets', { sourceSessionId })).targets; + } + + async sendMailboxMessage(targetSessionId: string, text: string) { + const sourceSessionId = this.#requireSession('send a Session message'); + return this.#request('session.mailbox.send', { + sourceSessionId, + targetSessionId, + messageId: this.#newId(), + kind: 'request', + text, + }); + } + getSessionResumeAvailability(session: SessionSummary): Promise { return inspectRuntimeHostSessionResumeAvailability(session, this.#executionLocation); } diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index e9fe200a6e..6034332eed 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -28,7 +28,12 @@ import type { CreateSessionInput, TurnOrchestration } from '@maka/core/runtime-i import type { UserQuestionResponse } from '@maka/core/user-question'; import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; -import type { GoalControlAction, GoalProjection } from '@maka/runtime-host/protocol'; +import type { + GoalControlAction, + GoalProjection, + SessionMailboxSendResult, + SessionMailboxTarget, +} from '@maka/runtime-host/protocol'; export interface MakaSessionMoveResult { previousCwd: string; @@ -99,6 +104,8 @@ export class SkillInvocationBlockedError extends Error { export interface MakaSessionDriver { listSessions(): Promise; + listMailboxTargets?(): Promise; + sendMailboxMessage?(targetSessionId: string, text: string): Promise; getSessionResumeAvailability?(session: SessionSummary): Promise; preparePrompt( prompt: string, diff --git a/packages/cli/src/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts index 71f25d76e0..7cb2600206 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -64,6 +64,7 @@ const TUI_PRIMARY_GUIDANCE = { resume: '从安全边界恢复最近一次中断的执行', rewind: '回退到较早的对话轮次', session: '切换或恢复会话', + send: '先选择同一项目中的接收会话,再输入消息', setup: '配置模型提供商(API Key)', side: '打开临时 Side Conversation', skill: '调用 Skill(也可直接输入 /skill:)', @@ -112,6 +113,7 @@ const TUI_PRIMARY_GUIDANCE = { resume: 'Resume latest interrupted run at a safe boundary', rewind: 'Rewind to an earlier turn', session: 'Resume session', + send: 'Choose another Session in this project, then write the message', setup: 'Set up a model provider (API key)', side: 'Open a temporary side conversation', skill: 'Invoke a skill (or type /skill: inline)', diff --git a/packages/core/package.json b/packages/core/package.json index 88bb6d7655..1855822ced 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -107,6 +107,8 @@ "./usage-stats/bucket-key": "./dist/usage-stats/bucket-key.js", "./usage-record-schema": "./dist/usage-record-schema.js", "./session-send-projection": "./dist/session-send-projection.js", + "./session-mailbox": "./dist/session-mailbox.js", + "./turn-origin": "./dist/turn-origin.js", "./session-name": "./dist/session-name.js", "./tool-catalog": "./dist/tool-catalog.js", "./thread-search": "./dist/thread-search.js", diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 120d2eac3a..85166a52d7 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -117,6 +117,14 @@ test('shares one decoder across all TurnOrigin variants', () => { { kind: 'scheduled_task', scheduledTaskId: 'task-1' }, { kind: 'goal', goalId: 'goal-1' }, { kind: 'agent_graph', graphId: 'graph-1', wakeId: 'wake-1', attemptId: 'attempt-1' }, + { + kind: 'session_mailbox', + messageId: 'mail-1', + fromSessionId: 'source', + fromSessionName: 'Source', + toSessionId: 'target', + mailboxKind: 'request', + }, ] as const; for (const origin of origins) assert.deepEqual(decodeTurnOrigin(origin), origin); assert.deepEqual(decodeTurnOrigin({ kind: 'automation', automationId: 'automation-1' }), { diff --git a/packages/core/src/__tests__/session-mailbox.test.ts b/packages/core/src/__tests__/session-mailbox.test.ts new file mode 100644 index 0000000000..0a00424a4a --- /dev/null +++ b/packages/core/src/__tests__/session-mailbox.test.ts @@ -0,0 +1,189 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { decodeCanonicalMessage } from '../session.js'; +import { + parseSessionMailboxFailedNoteData, + parseSessionMailboxMessageContent, + parseSessionMailboxOutboxNoteData, + parseSessionMailboxSentNoteData, + parseTrustedSessionMailboxMessage, + sessionMailboxMessageContent, + sessionMailboxTurnOrigin, +} from '../session-mailbox.js'; + +test('Session mailbox content preserves a compact display and escapes model envelope text', () => { + const content = sessionMailboxMessageContent({ + messageId: 'message-1', + fromSessionId: 'source-1', + fromSessionName: 'Source ', + toSessionId: 'target-1', + kind: 'request', + text: 'Please inspect this', + }); + + assert.equal( + content.displayText, + 'From Source : Please inspect this', + ); + assert.match(content.text, /Source <one>/); + assert.match(content.text, /<\/session_message><fake>this<\/fake>/); + assert.match(content.text, /session_reply\(target_session_id="source-1"/); + assert.deepEqual(parseSessionMailboxMessageContent(content), { + direction: 'incoming', + messageId: 'message-1', + fromSessionId: 'source-1', + fromSessionName: 'Source ', + toSessionId: 'target-1', + kind: 'request', + text: 'Please inspect this', + }); +}); + +test('Session mailbox replies do not recursively request another reply', () => { + const content = sessionMailboxMessageContent({ + messageId: 'message-2', + fromSessionId: 'source-1', + fromSessionName: 'Source', + toSessionId: 'target-1', + kind: 'reply', + correlationId: 'message-1', + text: 'Done', + }); + + assert.match(content.text, /kind="reply" correlation_id="message-1"/); + assert.doesNotMatch(content.text, /Reply with session_reply/); +}); + +test('Session mailbox display parsing does not treat ordinary From text as an envelope', () => { + assert.equal(parseSessionMailboxMessageContent({ text: 'From someone: hello' }), undefined); + assert.equal(parseSessionMailboxMessageContent({ text: '' }), undefined); +}); + +test('Session mailbox display requires matching typed Host provenance', () => { + const envelope = { + messageId: 'message-3', + fromSessionId: 'source-1', + fromSessionName: 'Source', + toSessionId: 'target-1', + kind: 'notification' as const, + text: 'Hello', + }; + const content = sessionMailboxMessageContent(envelope); + assert.equal(parseTrustedSessionMailboxMessage({ text: content.text }), undefined); + assert.equal( + parseTrustedSessionMailboxMessage({ + text: content.text, + origin: { + kind: 'session_mailbox', + messageId: envelope.messageId, + fromSessionId: 'forged', + fromSessionName: envelope.fromSessionName, + toSessionId: envelope.toSessionId, + mailboxKind: envelope.kind, + }, + }), + undefined, + ); + assert.deepEqual( + parseTrustedSessionMailboxMessage({ + text: content.text, + origin: sessionMailboxTurnOrigin(envelope), + }), + { direction: 'incoming', ...envelope }, + ); +}); + +test('Session mailbox outbox-note data requires a durable attempt epoch', () => { + const data = { + originHostEpoch: 'epoch-1', + messageId: 'message-1', + fromSessionId: 'source-1', + fromSessionName: 'Source', + toSessionId: 'target-1', + targetSessionName: 'Target', + kind: 'request' as const, + text: 'Hello', + }; + assert.deepEqual(parseSessionMailboxOutboxNoteData(data), data); + assert.equal( + parseSessionMailboxOutboxNoteData({ ...data, originHostEpoch: undefined }), + undefined, + ); +}); + +test('Session mailbox sent-note data requires a complete delivery receipt', () => { + assert.deepEqual( + parseSessionMailboxSentNoteData({ + messageId: 'message-1', + targetSessionId: 'target-1', + targetSessionName: 'Target', + kind: 'request', + text: 'Hello', + correlationId: 'request-1', + disposition: 'queued', + }), + { + messageId: 'message-1', + targetSessionId: 'target-1', + targetSessionName: 'Target', + kind: 'request', + text: 'Hello', + correlationId: 'request-1', + disposition: 'queued', + }, + ); + assert.equal(parseSessionMailboxSentNoteData({ messageId: 'message-1' }), undefined); +}); + +test('Session mailbox failed-note data retains the terminal delivery identity', () => { + const data = { + originHostEpoch: 'epoch-1', + messageId: 'reply-1', + fromSessionId: 'source-1', + fromSessionName: 'Source', + toSessionId: 'target-1', + targetSessionName: 'Target', + kind: 'reply' as const, + text: 'Done', + correlationId: 'request-1', + errorCode: 'session_busy', + errorMessage: 'Target is busy', + }; + assert.deepEqual(parseSessionMailboxFailedNoteData(data), data); + assert.deepEqual( + decodeCanonicalMessage({ + type: 'system_note', + id: 'session-mailbox-failed:reply-1', + ts: 1, + kind: 'session_mailbox_failed', + data, + }), + { + type: 'system_note', + id: 'session-mailbox-failed:reply-1', + ts: 1, + kind: 'session_mailbox_failed', + data, + }, + ); + assert.equal(parseSessionMailboxFailedNoteData({ ...data, errorCode: undefined }), undefined); +}); diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index 8c3dbd0275..f8b38da0a5 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -43,6 +43,7 @@ import { import type { AgentGraphIntentClaim } from './agent-graph-control.js'; import { isToolMode, type ToolMode } from './tool-mode.js'; import { decodeRunCompositionSnapshot, type RunCompositionSnapshot } from './run-composition.js'; +import type { TurnOrigin } from './turn-origin.js'; export const AGENT_RUN_STATUSES = [ 'created', @@ -79,6 +80,8 @@ export type RootExecutionDescriptor = kind: 'external_message'; inputDigest?: `sha256:${string}`; maxSteps?: number; + /** Host-authored source for non-user messages admitted through the message authority. */ + origin?: TurnOrigin; } | { /** Tool-free conversational execution admitted only by WorkHub authority. */ diff --git a/packages/core/src/session-mailbox.ts b/packages/core/src/session-mailbox.ts new file mode 100644 index 0000000000..47970c143b --- /dev/null +++ b/packages/core/src/session-mailbox.ts @@ -0,0 +1,317 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { MessageContent } from './events.js'; +import type { TurnOrigin } from './turn-origin.js'; + +export const SESSION_MAILBOX_TEXT_MAX_BYTES = 16_000; +export const SESSION_MAILBOX_KINDS = ['request', 'reply', 'notification'] as const; +export type SessionMailboxKind = (typeof SESSION_MAILBOX_KINDS)[number]; + +export interface SessionMailboxEnvelope { + readonly messageId: string; + readonly fromSessionId: string; + readonly fromSessionName: string; + readonly toSessionId: string; + readonly kind: SessionMailboxKind; + readonly text: string; + readonly correlationId?: string; +} + +export interface SessionMailboxIncomingDisplay { + readonly direction: 'incoming'; + readonly messageId: string; + readonly fromSessionId: string; + readonly fromSessionName: string; + readonly toSessionId: string; + readonly kind: SessionMailboxKind; + readonly text: string; + readonly correlationId?: string; +} + +export interface SessionMailboxSentNoteData { + readonly messageId: string; + readonly targetSessionId: string; + readonly targetSessionName: string; + readonly kind: SessionMailboxKind; + readonly text: string; + readonly correlationId?: string; + readonly disposition: 'turn_started' | 'queued'; + readonly turnId?: string; +} + +/** Durable sender-side intent written before target admission begins. */ +export interface SessionMailboxOutboxNoteData extends SessionMailboxEnvelope { + readonly originHostEpoch: string; + readonly targetSessionName: string; +} + +/** Durable terminal rejection for one sender-side delivery identity. */ +export interface SessionMailboxFailedNoteData extends SessionMailboxOutboxNoteData { + readonly errorCode: string; + readonly errorMessage: string; +} + +export function sessionMailboxSentReceiptId(messageId: string): string { + return `session-mailbox-sent:${messageId}`; +} + +export function sessionMailboxFailedReceiptId(messageId: string): string { + return `session-mailbox-failed:${messageId}`; +} + +export function sessionMailboxOutboxAttemptId(messageId: string, originHostEpoch: string): string { + return `session-mailbox-outbox:${messageId}:${originHostEpoch}`; +} + +export function sessionMailboxTurnOrigin(envelope: SessionMailboxEnvelope): TurnOrigin { + return { + kind: 'session_mailbox', + messageId: envelope.messageId, + fromSessionId: envelope.fromSessionId, + fromSessionName: envelope.fromSessionName, + toSessionId: envelope.toSessionId, + mailboxKind: envelope.kind, + ...(envelope.correlationId ? { correlationId: envelope.correlationId } : {}), + }; +} + +/** + * Session messages are ordinary canonical user messages at the execution + * boundary. The model-facing text carries an explicit Host-authored + * envelope while the human-facing transcript stays compact. + */ +export function sessionMailboxMessageContent(envelope: SessionMailboxEnvelope): MessageContent { + const attributes = [ + `message_id="${escapeAttribute(envelope.messageId)}"`, + `from_session_id="${escapeAttribute(envelope.fromSessionId)}"`, + `from_session_name="${escapeAttribute(envelope.fromSessionName)}"`, + `to_session_id="${escapeAttribute(envelope.toSessionId)}"`, + `kind="${envelope.kind}"`, + ...(envelope.correlationId + ? [`correlation_id="${escapeAttribute(envelope.correlationId)}"`] + : []), + ].join(' '); + const replyInstruction = + envelope.kind === 'request' + ? `\nReply with session_reply(target_session_id=\"${envelope.fromSessionId}\", in_reply_to=\"${envelope.messageId}\", text=...).` + : ''; + return { + text: [ + ``, + `From session \"${escapeText(envelope.fromSessionName)}\":`, + escapeText(envelope.text), + replyInstruction, + '', + ].join('\n'), + displayText: `From ${envelope.fromSessionName}: ${envelope.text}`, + inlineReferences: [], + }; +} + +/** + * Recovers the display fields from the exact Host-authored envelope. This is + * deliberately stricter than looking for a human-readable `From:` prefix, so + * ordinary user messages can never accidentally turn into mailbox cards. + */ +export function parseSessionMailboxMessageContent( + content: Pick, +): SessionMailboxIncomingDisplay | undefined { + const opening = content.text.match(/^\n]+)>\n/); + if (!opening || !content.text.endsWith('\n')) return undefined; + const attributes = parseAttributes(opening[1] ?? ''); + const messageId = attributes.message_id; + const fromSessionId = attributes.from_session_id; + const toSessionId = attributes.to_session_id; + const kind = attributes.kind; + if ( + !messageId || + !fromSessionId || + !toSessionId || + !SESSION_MAILBOX_KINDS.includes(kind as SessionMailboxKind) + ) { + return undefined; + } + + const inner = content.text.slice(opening[0].length, -'\n'.length); + const firstLineEnd = inner.indexOf('\n'); + if (firstLineEnd < 0) return undefined; + const sourceLine = inner.slice(0, firstLineEnd); + const legacySource = sourceLine.match(/^From session "([\s\S]*)":$/); + const fromSessionName = attributes.from_session_name ?? legacySource?.[1]; + if (fromSessionName === undefined) return undefined; + + let body = inner.slice(firstLineEnd + 1); + if (kind === 'request') { + const replyInstruction = + `\n\nReply with session_reply(target_session_id="${fromSessionId}", ` + + `in_reply_to="${messageId}", text=...).`; + if (!body.endsWith(replyInstruction)) return undefined; + body = body.slice(0, -replyInstruction.length); + } else { + if (!body.endsWith('\n')) return undefined; + body = body.slice(0, -1); + } + + return { + direction: 'incoming', + messageId: unescapeText(messageId), + fromSessionId: unescapeText(fromSessionId), + fromSessionName: unescapeText(fromSessionName), + toSessionId: unescapeText(toSessionId), + kind: kind as SessionMailboxKind, + text: unescapeText(body), + ...(attributes.correlation_id + ? { correlationId: unescapeText(attributes.correlation_id) } + : {}), + }; +} + +/** + * Projects a mailbox envelope only when the durable UserMessage carries the + * matching Runtime Host-authored origin. Text syntax alone is never provenance. + */ +export function parseTrustedSessionMailboxMessage(input: { + readonly text: string; + readonly origin?: TurnOrigin; +}): SessionMailboxIncomingDisplay | undefined { + if (input.origin?.kind !== 'session_mailbox') return undefined; + const parsed = parseSessionMailboxMessageContent(input); + if (!parsed) return undefined; + return parsed.messageId === input.origin.messageId && + parsed.fromSessionId === input.origin.fromSessionId && + parsed.fromSessionName === input.origin.fromSessionName && + parsed.toSessionId === input.origin.toSessionId && + parsed.kind === input.origin.mailboxKind && + parsed.correlationId === input.origin.correlationId + ? parsed + : undefined; +} + +export function parseSessionMailboxOutboxNoteData( + value: unknown, +): SessionMailboxOutboxNoteData | undefined { + if (!isRecord(value)) return undefined; + if ( + typeof value.originHostEpoch !== 'string' || + typeof value.messageId !== 'string' || + typeof value.fromSessionId !== 'string' || + typeof value.fromSessionName !== 'string' || + typeof value.toSessionId !== 'string' || + typeof value.targetSessionName !== 'string' || + typeof value.text !== 'string' || + !SESSION_MAILBOX_KINDS.includes(value.kind as SessionMailboxKind) || + (value.correlationId !== undefined && typeof value.correlationId !== 'string') + ) { + return undefined; + } + return { + originHostEpoch: value.originHostEpoch, + messageId: value.messageId, + fromSessionId: value.fromSessionId, + fromSessionName: value.fromSessionName, + toSessionId: value.toSessionId, + targetSessionName: value.targetSessionName, + kind: value.kind as SessionMailboxKind, + text: value.text, + ...(value.correlationId !== undefined ? { correlationId: value.correlationId } : {}), + }; +} + +export function parseSessionMailboxSentNoteData( + value: unknown, +): SessionMailboxSentNoteData | undefined { + if (!isRecord(value)) return undefined; + if ( + typeof value.messageId !== 'string' || + typeof value.targetSessionId !== 'string' || + typeof value.targetSessionName !== 'string' || + typeof value.text !== 'string' || + !SESSION_MAILBOX_KINDS.includes(value.kind as SessionMailboxKind) || + (value.correlationId !== undefined && typeof value.correlationId !== 'string') || + (value.disposition !== 'turn_started' && value.disposition !== 'queued') || + (value.turnId !== undefined && typeof value.turnId !== 'string') + ) { + return undefined; + } + return { + messageId: value.messageId, + targetSessionId: value.targetSessionId, + targetSessionName: value.targetSessionName, + kind: value.kind as SessionMailboxKind, + text: value.text, + ...(value.correlationId !== undefined ? { correlationId: value.correlationId } : {}), + disposition: value.disposition, + ...(value.turnId !== undefined ? { turnId: value.turnId } : {}), + }; +} + +export function parseSessionMailboxFailedNoteData( + value: unknown, +): SessionMailboxFailedNoteData | undefined { + const outbox = parseSessionMailboxOutboxNoteData(value); + if ( + !outbox || + !isRecord(value) || + typeof value.errorCode !== 'string' || + value.errorCode.length === 0 || + typeof value.errorMessage !== 'string' + ) { + return undefined; + } + return { + ...outbox, + errorCode: value.errorCode, + errorMessage: value.errorMessage, + }; +} + +function escapeAttribute(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + +function escapeText(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} + +function parseAttributes(value: string): Record { + const attributes: Record = {}; + for (const match of value.matchAll(/(?:^| )([a-z_]+)="([^"]*)"/g)) { + const key = match[1]; + const attributeValue = match[2]; + if (key !== undefined && attributeValue !== undefined) attributes[key] = attributeValue; + } + return attributes; +} + +function unescapeText(value: string): string { + return value + .replaceAll('"', '"') + .replaceAll('>', '>') + .replaceAll('<', '<') + .replaceAll('&', '&'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e9896dc2e2..3f2022b7e5 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -947,6 +947,9 @@ export interface SystemNoteMessage { | 'context_compacted' | 'context_compaction_failed_open' | 'step_limit' + | 'session_mailbox_outbox' + | 'session_mailbox_failed' + | 'session_mailbox_sent' | 'error' | 'abort'; /** Shape depends on `kind`. */ @@ -1050,6 +1053,9 @@ const SYSTEM_NOTE_KINDS = new Set([ 'context_compacted', 'context_compaction_failed_open', 'step_limit', + 'session_mailbox_outbox', + 'session_mailbox_failed', + 'session_mailbox_sent', 'error', 'abort', ]); diff --git a/packages/core/src/slash-command-catalog.ts b/packages/core/src/slash-command-catalog.ts index c9a8937dd4..d73f8f305c 100644 --- a/packages/core/src/slash-command-catalog.ts +++ b/packages/core/src/slash-command-catalog.ts @@ -39,10 +39,11 @@ export const SLASH_COMMAND_CATALOG = [ { id: 'new', session: 'none', surfaces: ['tui'] }, { id: 'permissions', session: 'required', surfaces: ['tui'] }, { id: 'recap', session: 'required', surfaces: ['tui'] }, - { id: 'rename', session: 'required', surfaces: ['tui'] }, + { id: 'rename', session: 'required', surfaces: ['desktop', 'tui'] }, { id: 'resume', session: 'required', surfaces: ['tui'] }, { id: 'rewind', session: 'required', surfaces: ['tui'] }, { id: 'session', session: 'none', surfaces: ['tui'] }, + { id: 'send', session: 'required', surfaces: ['desktop', 'tui'] }, { id: 'setup', session: 'none', surfaces: ['tui'] }, { id: 'side', session: 'required', surfaces: ['desktop', 'tui'] }, { id: 'skill', session: 'required', surfaces: ['tui'] }, diff --git a/packages/core/src/turn-origin.ts b/packages/core/src/turn-origin.ts index 854a51631d..37f3c75004 100644 --- a/packages/core/src/turn-origin.ts +++ b/packages/core/src/turn-origin.ts @@ -31,12 +31,23 @@ export type TurnOrigin = wakeId: string; /** Durable identity of one delivery attempt for the wake. */ attemptId: string; + } + | { + /** Runtime Host-authored provenance for one cross-Session mailbox message. */ + kind: 'session_mailbox'; + messageId: string; + fromSessionId: string; + fromSessionName: string; + toSessionId: string; + mailboxKind: 'request' | 'reply' | 'notification'; + correlationId?: string; }; type ScheduledTaskOrigin = Extract; type LegacyAutomationOrigin = Extract; type GoalOrigin = Extract; type AgentGraphOrigin = Extract; +type SessionMailboxOrigin = Extract; const SCHEDULED_TASK_ORIGIN_SHAPE = defineObjectShape()( ['kind', 'scheduledTaskId'], @@ -51,6 +62,10 @@ const AGENT_GRAPH_ORIGIN_SHAPE = defineObjectShape()( ['kind', 'graphId', 'wakeId', 'attemptId'], [], ); +const SESSION_MAILBOX_ORIGIN_SHAPE = defineObjectShape()( + ['kind', 'messageId', 'fromSessionId', 'fromSessionName', 'toSessionId', 'mailboxKind'], + ['correlationId'], +); /** Decode a persisted or runtime turn origin, normalizing released Automation rows. */ export function decodeTurnOrigin(value: unknown): TurnOrigin | undefined { @@ -90,5 +105,27 @@ export function decodeTurnOrigin(value: unknown): TurnOrigin | undefined { attemptId: value.attemptId, }; } + if ( + hasExactShape(value, SESSION_MAILBOX_ORIGIN_SHAPE) && + value.kind === 'session_mailbox' && + typeof value.messageId === 'string' && + typeof value.fromSessionId === 'string' && + typeof value.fromSessionName === 'string' && + typeof value.toSessionId === 'string' && + (value.mailboxKind === 'request' || + value.mailboxKind === 'reply' || + value.mailboxKind === 'notification') && + (value.correlationId === undefined || typeof value.correlationId === 'string') + ) { + return { + kind: 'session_mailbox', + messageId: value.messageId, + fromSessionId: value.fromSessionId, + fromSessionName: value.fromSessionName, + toSessionId: value.toSessionId, + mailboxKind: value.mailboxKind, + ...(value.correlationId !== undefined ? { correlationId: value.correlationId } : {}), + }; + } return undefined; } diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 1e98782511..6b21b667e2 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -1481,6 +1481,9 @@ test('production Host executes a canonical ai-sdk Session against a real provide 'memory_extract', 'memory_remember', 'request_sandbox_boundary', + 'session_list', + 'session_reply', + 'session_send', 'task_create', 'task_get', 'task_list', diff --git a/packages/runtime-host/src/__tests__/message-coordinator.test.ts b/packages/runtime-host/src/__tests__/message-coordinator.test.ts index 4763c12670..af346f52ce 100644 --- a/packages/runtime-host/src/__tests__/message-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/message-coordinator.test.ts @@ -72,6 +72,94 @@ test('idle submit starts exactly one root Turn and retry identity is connection- assert.equal(fixture.liveResidencies(), 0); }); +test('trusted submit receipts bind mailbox provenance across reconciliation', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + const owner = fixture.coordinator.bindRun(ROOT); + const input = { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + messageId: 'mailbox-message', + content: { text: 'trusted mailbox payload' }, + placement: 'next_turn', + } as const; + const origin = { + kind: 'session_mailbox', + messageId: 'mailbox-message', + fromSessionId: 'source', + fromSessionName: 'Source', + toSessionId: ROOT.sessionId, + mailboxKind: 'request', + } as const; + + const submitted = await fixture.coordinator.submitTrusted(input, operationContext(), origin); + assert.equal(submitted.ok, true); + assert.deepEqual(await fixture.coordinator.reconcileTrustedSubmit(input, origin), submitted); + const conflict = await fixture.coordinator.reconcileTrustedSubmit(input, { + ...origin, + fromSessionId: 'forged', + }); + assert.equal(conflict?.ok, false); + const publicRetry = await fixture.coordinator.handlers['turn.message.submit']( + input, + operationContext(), + ); + assert.equal(publicRetry.ok, false); + + owner.release(); + const batch = fixture.coordinator.beginTerminalTransition(ROOT); + assert.deepEqual(batch.sources[0]?.origin, origin); + const nextRoot = { sessionId: ROOT.sessionId, turnId: 'mailbox-turn', runId: 'mailbox-run' }; + fixture.coordinator.commitNextRoot(batch, nextRoot); + fixture.coordinator.abandonRootReservation(nextRoot); +}); + +test('trusted follow-ups retain per-source provenance in one root handoff', async () => { + const fixture = createFixture(); + fixture.coordinator.reserveRootTurn(ROOT); + const owner = fixture.coordinator.bindRun(ROOT); + const trusted = (messageId: string) => { + const origin = { + kind: 'session_mailbox' as const, + messageId, + fromSessionId: 'source', + fromSessionName: 'Source', + toSessionId: ROOT.sessionId, + mailboxKind: 'request' as const, + }; + return fixture.coordinator.submitTrusted( + { + originHostEpoch: 'epoch-1', + sessionId: ROOT.sessionId, + messageId, + content: { text: `trusted mailbox payload ${messageId}` }, + placement: 'next_turn', + }, + operationContext(), + origin, + ); + }; + + assert.equal((await trusted('mailbox-message-1')).ok, true); + assert.equal((await trusted('mailbox-message-2')).ok, true); + owner.release(); + + const batch = fixture.coordinator.beginTerminalTransition(ROOT); + assert.deepEqual( + batch.sources.map((source) => source.messageId), + ['mailbox-message-1', 'mailbox-message-2'], + ); + assert.deepEqual( + batch.sources.map((source) => + source.origin?.kind === 'session_mailbox' ? source.origin.messageId : undefined, + ), + ['mailbox-message-1', 'mailbox-message-2'], + ); + const secondRoot = { sessionId: ROOT.sessionId, turnId: 'turn-2', runId: 'run-2' }; + fixture.coordinator.commitNextRoot(batch, secondRoot); + fixture.coordinator.abandonRootReservation(secondRoot); +}); + test('submit re-runs admission when the queue revision moves during preflight', async () => { let preflightCalls = 0; const fixture = createFixture(undefined, async () => { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index c4f2fad83b..2e942fe4a7 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -189,6 +189,12 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 47); }); + test('publishes a new compatibility epoch for Session mailbox operations', () => { + // Epoch 48 peers reject the two new closed operation keys, so they must + // fail the handshake before either side attempts Session messaging. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 48); + }); + test('adds credential rotation without changing existing credential inputs', () => { const issueInput = { principalKind: 'remote_owner', @@ -1104,6 +1110,46 @@ describe('Runtime Host bootstrap protocol', () => { ); }); + test('keeps Session mailbox discovery and delivery closed and bounded', () => { + const targets = { + requestId: 'mailbox-targets-1', + operation: 'session.mailbox.targets' as const, + input: { sourceSessionId: 'session-1' }, + }; + const send = { + requestId: 'mailbox-send-1', + operation: 'session.mailbox.send' as const, + input: { + sourceSessionId: 'session-1', + targetSessionId: 'session-2', + messageId: 'message-1', + kind: 'request' as const, + text: 'Please inspect this', + }, + }; + assert.deepEqual(decodeClientFrame(targets), targets); + assert.deepEqual(decodeClientFrame(send), send); + assert.throws( + () => decodeClientFrame({ ...send, input: { ...send.input, placement: 'current_turn' } }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + requestId: send.requestId, + operation: send.operation, + ok: true, + result: { + messageId: 'message-1', + targetSessionId: 'session-2', + disposition: 'queued', + turnId: 'turn-invalid', + }, + }), + isInvalidFrame, + ); + }); + test('decodes old-Epoch ambiguity only for operations that declare outcome_unknown', () => { const response = { requestId: 'submit-old-epoch', diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 5f62a61b7b..e7233b2ede 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -56,6 +56,11 @@ import type { BackendSendInput, } from '@maka/core/backend-types'; import type { SessionEvent } from '@maka/core/events'; +import { + parseTrustedSessionMailboxMessage, + sessionMailboxMessageContent, + sessionMailboxTurnOrigin, +} from '@maka/core/session-mailbox'; import { WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_SESSION_ROLE, @@ -3507,6 +3512,98 @@ test('mixed-Client queued follow-ups use one Session successor without connectio } }); +test('queued mailbox messages persist as separately trusted UserMessages', { + timeout: 20_000, +}, async () => { + let backend: LinkedChildAuthorityBackend | undefined; + const fixture = await createFailureFixture({ + registerBackend: (backends) => { + backends.register('ai-sdk', (context) => { + backend = new LinkedChildAuthorityBackend(context.sessionId); + return backend; + }); + }, + }); + + try { + const firstTurnId = 'turn-before-mailbox-followups'; + const started = await fixture.interactiveTurns.handlers['turn.start']( + { + sessionId: fixture.sessionId, + turnId: firstTurnId, + content: { text: HOLD_EXTERNAL_PROMPT }, + }, + operationContext(fixture.hostEpoch, fixture.acquireResidency), + ); + assertStartedTurn(started); + + const envelopes = ['first queued message', 'second queued message'].map((text, index) => ({ + messageId: `mailbox-followup-${index + 1}`, + fromSessionId: 'source-session', + fromSessionName: 'Source session', + toSessionId: fixture.sessionId, + kind: 'request' as const, + text, + })); + for (const envelope of envelopes) { + const submitted = await fixture.messages.submitTrusted( + { + originHostEpoch: fixture.hostEpoch, + sessionId: fixture.sessionId, + messageId: envelope.messageId, + content: sessionMailboxMessageContent(envelope), + placement: 'next_turn', + }, + operationContext(fixture.hostEpoch, fixture.acquireResidency), + sessionMailboxTurnOrigin(envelope), + ); + assert.equal(submitted.ok && submitted.result.disposition, 'followup'); + } + + backend?.release(); + await waitUntil( + () => fixture.coordinator.readRootState(fixture.sessionId).kind === 'idle', + 5_000, + ); + + const admissions = await fixture.stores.agentRunStore.listRootTurnAdmissionsForRecovery( + fixture.sessionId, + ); + assert.deepEqual( + admissions.map((admission) => admission.sourceMessages.map((source) => source.messageId)), + [[], ['mailbox-followup-1', 'mailbox-followup-2']], + ); + assert.deepEqual( + admissions[1]?.sourceMessages.map((source) => source.origin), + envelopes.map(sessionMailboxTurnOrigin), + ); + assert.equal( + admissions[1]?.execution.kind === 'external_message' + ? admissions[1].execution.origin + : undefined, + undefined, + ); + + const messages = await fixture.stores.sessionStore.readMessages(fixture.sessionId); + const recoveredMailboxMessages = messages + .filter((message) => message.type === 'user' && message.origin?.kind === 'session_mailbox') + .map((message) => { + assert.equal(message.type, 'user'); + if (message.type !== 'user') return undefined; + return parseTrustedSessionMailboxMessage(message); + }); + assert.deepEqual( + recoveredMailboxMessages.map((message) => message?.text), + envelopes.map((envelope) => envelope.text), + ); + } finally { + backend?.release(); + await fixture.coordinator.close(); + await fixture.messages.close(); + await fixture.dispose(); + } +}); + test('queued follow-up does not bind lost or ambiguous connection-local tools', { timeout: 20_000, }, async () => { diff --git a/packages/runtime-host/src/__tests__/session-mailbox-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-mailbox-coordinator.test.ts new file mode 100644 index 0000000000..ad7c87f496 --- /dev/null +++ b/packages/runtime-host/src/__tests__/session-mailbox-coordinator.test.ts @@ -0,0 +1,488 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { SessionSummary, StoredMessage } from '@maka/core/session'; +import type { HostMessageCoordinator } from '../server/message-coordinator.js'; +import type { ConnectionContext } from '../server/operation-dispatcher.js'; +import { HostSessionMailboxCoordinator } from '../server/session-mailbox-coordinator.js'; + +test('Session mailbox exposes only ordinary Sessions in the source project', async () => { + const coordinator = mailbox([ + session('source', '/one', { projectId: 'project-1' }), + session('target', '/one', { projectId: 'project-1', runningTurnIds: ['turn-live'] }), + session('other-project', '/one', { projectId: 'project-2' }), + session('side', '/one', { projectId: 'project-1', labels: ['mode:side_conversation'] }), + session('child', '/one', { + projectId: 'project-1', + subagentParent: { + kind: 'subagent', + parentSessionId: 'source', + spawnedBy: { parentRunId: 'run-1', parentTurnId: 'turn-1', toolCallId: 'call-1' }, + lifecycle: 'foreground', + }, + }), + ]); + + const outcome = await coordinator.handlers['session.mailbox.targets']( + { sourceSessionId: 'source' }, + connection(), + ); + + assert.deepEqual(outcome, { + ok: true, + result: { + targets: [{ sessionId: 'target', name: 'target', status: 'running' }], + }, + }); +}); + +test('Session mailbox submits a next-turn message with Host provenance', async () => { + let submitted: unknown; + let submittedOrigin: unknown; + const stored: StoredMessage[] = [ + { + type: 'system_note', + id: 'start', + turnId: 'source-turn', + ts: 1, + kind: 'session_start', + }, + ]; + const coordinator = mailbox( + [session('source', '/one'), session('target', '/one')], + async (input, origin) => { + submitted = input; + submittedOrigin = origin; + return { ok: true, result: { disposition: 'turn_started', turnId: 'turn-2' } }; + }, + stored, + ); + + const outcome = await coordinator.handlers['session.mailbox.send']( + { + sourceSessionId: 'source', + targetSessionId: 'target', + messageId: 'message-1', + kind: 'request', + text: 'Please inspect this', + }, + connection(), + ); + + assert.deepEqual(outcome, { + ok: true, + result: { + messageId: 'message-1', + targetSessionId: 'target', + disposition: 'turn_started', + turnId: 'turn-2', + }, + }); + assert.deepEqual(submitted, { + originHostEpoch: 'epoch-1', + sessionId: 'target', + messageId: 'message-1', + content: { + text: [ + '', + 'From session "source":', + 'Please inspect this', + '', + 'Reply with session_reply(target_session_id="source", in_reply_to="message-1", text=...).', + '', + ].join('\n'), + displayText: 'From source: Please inspect this', + inlineReferences: [], + }, + placement: 'next_turn', + }); + assert.deepEqual(submittedOrigin, { + kind: 'session_mailbox', + messageId: 'message-1', + fromSessionId: 'source', + fromSessionName: 'source', + toSessionId: 'target', + mailboxKind: 'request', + }); + assert.equal(stored[1]?.type, 'system_note'); + if (stored[1]?.type === 'system_note') { + assert.equal(stored[1].kind, 'session_mailbox_outbox'); + } + assert.deepEqual(stored.at(-1), { + type: 'system_note', + id: 'session-mailbox-sent:message-1', + turnId: 'source-turn', + ts: 123, + kind: 'session_mailbox_sent', + data: { + messageId: 'message-1', + targetSessionId: 'target', + targetSessionName: 'target', + kind: 'request', + text: 'Please inspect this', + disposition: 'turn_started', + turnId: 'turn-2', + }, + }); +}); + +test('Session mailbox rejects self-send and cross-project targets', async () => { + const coordinator = mailbox([session('source', '/one'), session('target', '/two')]); + const self = await coordinator.handlers['session.mailbox.send']( + { + sourceSessionId: 'source', + targetSessionId: 'source', + messageId: 'message-1', + kind: 'notification', + text: 'No', + }, + connection(), + ); + const crossProject = await coordinator.handlers['session.mailbox.send']( + { + sourceSessionId: 'source', + targetSessionId: 'target', + messageId: 'message-2', + kind: 'notification', + text: 'No', + }, + connection(), + ); + + assert.equal(self.ok, false); + if (!self.ok) assert.equal(self.error.code, 'invalid_request'); + assert.equal(crossProject.ok, false); + if (!crossProject.ok) assert.equal(crossProject.error.code, 'not_found'); +}); + +test('Session mailbox never admits a target message before the sender outbox is durable', async () => { + let submitCount = 0; + const coordinator = new HostSessionMailboxCoordinator({ + hostEpoch: 'epoch-1', + messages: { + submitTrusted: async () => { + submitCount += 1; + return { ok: true, result: { disposition: 'followup', queueRevision: 1 } }; + }, + reconcileTrustedSubmit: async () => undefined, + } as unknown as Pick, + listSessions: async () => [session('source', '/one'), session('target', '/one')], + sessionStore: { + readMessagesSnapshot: async () => [], + appendMessage: async () => { + throw new Error('disk unavailable'); + }, + }, + }); + + await assert.rejects( + coordinator.handlers['session.mailbox.send']( + { + sourceSessionId: 'source', + targetSessionId: 'target', + messageId: 'message-1', + kind: 'request', + text: 'Do not lose me', + }, + connection(), + ), + /disk unavailable/, + ); + assert.equal(submitCount, 0); +}); + +test('Session mailbox recovery settles a durable outbox from target proof without redelivery', async () => { + const sessions = [session('source', '/one'), session('target', '/one')]; + const stored = new Map([ + ['source', []], + ['target', []], + ]); + let receiptAppendFails = true; + let initialSubmitCount = 0; + const store = { + readMessagesSnapshot: async (sessionId: string) => stored.get(sessionId) ?? [], + appendMessage: async (sessionId: string, message: StoredMessage) => { + if ( + receiptAppendFails && + message.type === 'system_note' && + message.kind === 'session_mailbox_sent' + ) { + throw new Error('receipt write failed'); + } + stored.get(sessionId)?.push(message); + }, + }; + const initial = new HostSessionMailboxCoordinator({ + hostEpoch: 'epoch-1', + messages: { + submitTrusted: async () => { + initialSubmitCount += 1; + return { ok: true, result: { disposition: 'turn_started', turnId: 'target-turn' } }; + }, + reconcileTrustedSubmit: async () => undefined, + } as unknown as Pick, + listSessions: async () => sessions, + sessionStore: store, + now: () => 10, + }); + + const sent = await initial.handlers['session.mailbox.send']( + { + sourceSessionId: 'source', + targetSessionId: 'target', + messageId: 'message-1', + kind: 'request', + text: 'Recover the receipt', + }, + connection(), + ); + assert.equal(sent.ok, true); + assert.equal(initialSubmitCount, 1); + assert.deepEqual( + stored + .get('source') + ?.map((message) => (message.type === 'system_note' ? message.kind : message.type)), + ['session_mailbox_outbox'], + ); + + receiptAppendFails = false; + let recoverySubmitCount = 0; + let reconcileCount = 0; + const recovered = new HostSessionMailboxCoordinator({ + hostEpoch: 'epoch-2', + messages: { + submitTrusted: async () => { + recoverySubmitCount += 1; + return { ok: true, result: { disposition: 'turn_started', turnId: 'duplicate' } }; + }, + reconcileTrustedSubmit: async () => { + reconcileCount += 1; + return { ok: true, result: { disposition: 'turn_started', turnId: 'target-turn' } }; + }, + } as unknown as Pick, + listSessions: async () => sessions, + sessionStore: store, + now: () => 20, + }); + await recovered.recover(); + + assert.equal(reconcileCount, 1); + assert.equal(recoverySubmitCount, 0); + assert.deepEqual( + stored + .get('source') + ?.map((message) => (message.type === 'system_note' ? message.kind : message.type)), + ['session_mailbox_outbox', 'session_mailbox_sent'], + ); +}); + +test('Session mailbox terminal rejection settles the outbox without delayed recovery delivery', async () => { + const stored: StoredMessage[] = []; + let submitCount = 0; + const coordinator = mailbox( + [session('source', '/one'), session('target', '/one')], + async () => { + submitCount += 1; + return { + ok: false, + error: { code: 'session_busy', message: 'Target Session is busy' }, + }; + }, + stored, + ); + const input = { + sourceSessionId: 'source', + targetSessionId: 'target', + messageId: 'message-rejected', + kind: 'request' as const, + text: 'Try once', + }; + + const rejected = await coordinator.handlers['session.mailbox.send'](input, connection()); + assert.equal(rejected.ok, false); + if (!rejected.ok) assert.equal(rejected.error.code, 'session_busy'); + assert.equal(submitCount, 1); + assert.deepEqual( + stored.map((message) => (message.type === 'system_note' ? message.kind : message.type)), + ['session_mailbox_outbox', 'session_mailbox_failed'], + ); + + const exactRetry = await coordinator.handlers['session.mailbox.send'](input, connection()); + assert.deepEqual(exactRetry, rejected); + await coordinator.recover(); + assert.equal(submitCount, 1); +}); + +test('Session mailbox keeps outcome-unknown delivery pending for proof-first recovery', async () => { + const stored: StoredMessage[] = []; + const messagesBySession = new Map([ + ['source', stored], + ['target', []], + ]); + let submitCount = 0; + let reconcileCount = 0; + const coordinator = new HostSessionMailboxCoordinator({ + hostEpoch: 'epoch-1', + messages: { + submitTrusted: async () => { + submitCount += 1; + return { + ok: false, + error: { code: 'outcome_unknown', message: 'Delivery outcome is unknown' }, + }; + }, + reconcileTrustedSubmit: async () => { + reconcileCount += 1; + return { + ok: false, + error: { code: 'outcome_unknown', message: 'Delivery outcome is still unknown' }, + }; + }, + } as unknown as Pick, + listSessions: async () => [session('source', '/one'), session('target', '/one')], + sessionStore: { + readMessagesSnapshot: async (sessionId) => messagesBySession.get(sessionId) ?? [], + appendMessage: async (sessionId, message) => { + messagesBySession.get(sessionId)?.push(message); + }, + }, + }); + + const result = await coordinator.handlers['session.mailbox.send']( + { + sourceSessionId: 'source', + targetSessionId: 'target', + messageId: 'message-unknown', + kind: 'request', + text: 'Confirm before retrying', + }, + connection(), + ); + assert.equal(result.ok, false); + if (!result.ok) assert.equal(result.error.code, 'outcome_unknown'); + assert.deepEqual( + stored.map((message) => (message.type === 'system_note' ? message.kind : message.type)), + ['session_mailbox_outbox'], + ); + + await coordinator.recover(); + assert.equal(submitCount, 1); + assert.equal(reconcileCount, 1); + assert.equal(stored.length, 1); +}); + +test('Session mailbox success receipt fences reply correlation drift', async () => { + const stored: StoredMessage[] = []; + let submitCount = 0; + const coordinator = mailbox( + [session('source', '/one'), session('target', '/one')], + async () => { + submitCount += 1; + return { ok: true, result: { disposition: 'followup', queueRevision: 1 } }; + }, + stored, + ); + const input = { + sourceSessionId: 'source', + targetSessionId: 'target', + messageId: 'reply-1', + kind: 'reply' as const, + text: 'Done', + correlationId: 'request-1', + }; + + const sent = await coordinator.handlers['session.mailbox.send'](input, connection()); + assert.equal(sent.ok, true); + assert.equal(submitCount, 1); + const receipt = stored.find( + (message) => message.type === 'system_note' && message.kind === 'session_mailbox_sent', + ); + assert.equal(receipt?.type, 'system_note'); + if (receipt?.type === 'system_note') { + assert.equal((receipt.data as { correlationId?: string }).correlationId, 'request-1'); + } + + const exactRetry = await coordinator.handlers['session.mailbox.send'](input, connection()); + assert.deepEqual(exactRetry, sent); + const drifted = await coordinator.handlers['session.mailbox.send']( + { ...input, correlationId: 'request-2' }, + connection(), + ); + assert.equal(drifted.ok, false); + if (!drifted.ok) assert.equal(drifted.error.code, 'operation_conflict'); + assert.equal(submitCount, 1); +}); + +function mailbox( + sessions: SessionSummary[], + submit: (input: unknown, origin?: unknown) => Promise = async () => ({ + ok: true, + result: { disposition: 'followup', queueRevision: 1 }, + }), + stored: StoredMessage[] = [], +): HostSessionMailboxCoordinator { + const messages = { + submitTrusted: async (input: unknown, _context: unknown, origin: unknown) => + submit(input, origin), + reconcileTrustedSubmit: async () => undefined, + } as unknown as Pick; + return new HostSessionMailboxCoordinator({ + hostEpoch: 'epoch-1', + messages, + listSessions: async () => sessions, + sessionStore: { + readMessagesSnapshot: async () => stored, + appendMessage: async (_sessionId, message) => { + stored.push(message); + }, + }, + createId: () => 'message-generated', + now: () => 123, + }); +} + +function session(id: string, cwd: string, overrides: Partial = {}): SessionSummary { + return { + id, + cwd, + name: id, + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionSlug: 'connection', + connectionLocked: true, + model: 'model', + permissionMode: 'ask', + ...overrides, + }; +} + +function connection(): ConnectionContext { + return { + hostEpoch: 'epoch-1', + connectionId: 'connection-1', + principal: 'owner', + acquireResidency: () => ({ release: () => undefined }), + }; +} diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 30f01b2cb7..eec649aca4 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -74,6 +74,7 @@ export * from './project-catalog.js'; export * from './project-catalog-change.js'; export * from './execution-inspect.js'; export * from './external-session.js'; +export * from './session-mailbox.js'; export * from './message.js'; export * from './operations.js'; export * from './runtime-resource.js'; @@ -92,7 +93,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 50 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 51 as const; +// 51: Session mailbox target discovery and durable cross-Session delivery. +// Epoch-50 peers do not know these closed operation keys or trusted provenance. // 50: WorkHub can append durable coordination summaries and admit tool-free // answers through its reserved Coordination Session authority. // 49: WorkHub resolves one durable Coordination Session per Runtime Host. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 9d95dec0bf..0f8e3d44fe 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -51,6 +51,7 @@ import { RUNTIME_RESOURCE_OPERATION_SPECS } from './runtime-resource.js'; import { SCHEDULED_TASK_OPERATION_SPECS } from './scheduled-task.js'; import { SESSION_CATALOG_OPERATION_SPECS } from './session-catalog.js'; import { SESSION_CONTINUITY_OPERATION_SPECS } from './session-continuity.js'; +import { SESSION_MAILBOX_OPERATION_SPECS } from './session-mailbox.js'; import { SESSION_TRANSCRIPT_OPERATION_SPECS } from './session-transcript.js'; import { SESSION_TURNS_OPERATION_SPECS } from './session-turns.js'; import { SESSION_REVISION_OPERATION_SPECS } from './session-revision.js'; @@ -165,6 +166,7 @@ export * from './runtime-policy.js'; export * from './runtime-resource.js'; export * from './scheduled-task.js'; export * from './session-catalog.js'; +export * from './session-mailbox.js'; export * from './session-revision.js'; export * from './session-retirement.js'; export * from './session-transcript.js'; @@ -200,6 +202,7 @@ export const HOST_OPERATION_SPECS = composeOperationSpecMaps( SESSION_TRANSCRIPT_OPERATION_SPECS, SESSION_TURNS_OPERATION_SPECS, SESSION_CATALOG_OPERATION_SPECS, + SESSION_MAILBOX_OPERATION_SPECS, SESSION_EFFECT_OPERATION_SPECS, SESSION_REVISION_OPERATION_SPECS, SESSION_RETIREMENT_OPERATION_SPECS, @@ -297,6 +300,8 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'session.create', 'session.execution_boundary.query', 'session.lifecycle.set', + 'session.mailbox.send', + 'session.mailbox.targets', 'session.metadata.update', 'session.read_marker.set', 'session.recap.generate', diff --git a/packages/runtime-host/src/protocol/session-mailbox.ts b/packages/runtime-host/src/protocol/session-mailbox.ts new file mode 100644 index 0000000000..a1559ccf33 --- /dev/null +++ b/packages/runtime-host/src/protocol/session-mailbox.ts @@ -0,0 +1,190 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + SESSION_MAILBOX_KINDS, + SESSION_MAILBOX_TEXT_MAX_BYTES, + type SessionMailboxKind, +} from '@maka/core/session-mailbox'; +import { + assertAllowedKeys, + requireEncodedByteLimit, + requireEntityId, + requireExactRecord, + requireRecord, + requireUtf8String, +} from './codec.js'; +import { invalidProtocolFrame } from './errors.js'; +import { defineOperation } from './operation-spec.js'; + +export const SESSION_MAILBOX_TARGET_MAX_ITEMS = 64; +export const SESSION_MAILBOX_RESULT_MAX_BYTES = 64 * 1024; + +export interface SessionMailboxTarget { + readonly sessionId: string; + readonly name: string; + readonly status: 'idle' | 'running' | 'waiting_for_user'; +} + +export interface SessionMailboxTargetsInput { + readonly sourceSessionId: string; +} + +export interface SessionMailboxTargetsResult { + readonly targets: readonly SessionMailboxTarget[]; +} + +export interface SessionMailboxSendInput { + readonly sourceSessionId: string; + readonly targetSessionId: string; + readonly messageId: string; + readonly kind: SessionMailboxKind; + readonly text: string; + readonly correlationId?: string; +} + +export interface SessionMailboxSendResult { + readonly messageId: string; + readonly targetSessionId: string; + readonly disposition: 'turn_started' | 'queued'; + readonly turnId?: string; +} + +const ERRORS = [ + 'host_not_ready', + 'host_draining', + 'operation_unavailable', + 'not_found', + 'session_archived', + 'session_busy', + 'operation_conflict', + 'invalid_request', + 'outcome_unknown', + 'internal_failure', +] as const; + +export const SESSION_MAILBOX_OPERATION_SPECS = { + 'session.mailbox.targets': defineOperation({ + mode: 'query', + availability: 'ready', + errors: ERRORS, + decodeInput: decodeTargetsInput, + decodeOutput: decodeTargetsResult, + }), + 'session.mailbox.send': defineOperation({ + mode: 'command', + availability: 'ready', + errors: ERRORS, + decodeInput: decodeSendInput, + decodeOutput: decodeSendResult, + }), +} as const; + +function decodeTargetsInput(value: unknown): SessionMailboxTargetsInput { + const record = requireExactRecord(value, 'session.mailbox.targets input', ['sourceSessionId']); + return { + sourceSessionId: requireEntityId(record.sourceSessionId, 'sourceSessionId'), + }; +} + +function decodeTargetsResult(value: unknown): SessionMailboxTargetsResult { + const record = requireExactRecord(value, 'session.mailbox.targets result', ['targets']); + if (!Array.isArray(record.targets) || record.targets.length > SESSION_MAILBOX_TARGET_MAX_ITEMS) { + throw invalidProtocolFrame('Invalid Session mailbox targets'); + } + const targets = record.targets.map((value) => { + const target = requireExactRecord(value, 'Session mailbox target', [ + 'sessionId', + 'name', + 'status', + ]); + if ( + target.status !== 'idle' && + target.status !== 'running' && + target.status !== 'waiting_for_user' + ) { + throw invalidProtocolFrame('Invalid Session mailbox target status'); + } + return { + sessionId: requireEntityId(target.sessionId, 'target sessionId'), + name: requireUtf8String(target.name, 'target name', 320), + status: target.status as SessionMailboxTarget['status'], + }; + }); + const result = { targets }; + requireEncodedByteLimit( + result, + 'session.mailbox.targets result', + SESSION_MAILBOX_RESULT_MAX_BYTES, + ); + return result; +} + +function decodeSendInput(value: unknown): SessionMailboxSendInput { + const record = requireRecord(value, 'session.mailbox.send input'); + assertAllowedKeys(record, 'session.mailbox.send input', [ + 'sourceSessionId', + 'targetSessionId', + 'messageId', + 'kind', + 'text', + 'correlationId', + ]); + if (!SESSION_MAILBOX_KINDS.includes(record.kind as SessionMailboxKind)) { + throw invalidProtocolFrame('Invalid Session mailbox message kind'); + } + return { + sourceSessionId: requireEntityId(record.sourceSessionId, 'sourceSessionId'), + targetSessionId: requireEntityId(record.targetSessionId, 'targetSessionId'), + messageId: requireEntityId(record.messageId, 'messageId'), + kind: record.kind as SessionMailboxKind, + text: requireUtf8String(record.text, 'mailbox text', SESSION_MAILBOX_TEXT_MAX_BYTES), + ...(record.correlationId === undefined + ? {} + : { + correlationId: requireEntityId(record.correlationId, 'correlationId'), + }), + }; +} + +function decodeSendResult(value: unknown): SessionMailboxSendResult { + const record = requireRecord(value, 'session.mailbox.send result'); + assertAllowedKeys(record, 'session.mailbox.send result', [ + 'messageId', + 'targetSessionId', + 'disposition', + 'turnId', + ]); + if (record.disposition !== 'turn_started' && record.disposition !== 'queued') { + throw invalidProtocolFrame('Invalid Session mailbox disposition'); + } + if ( + (record.disposition === 'turn_started' && record.turnId === undefined) || + (record.disposition === 'queued' && record.turnId !== undefined) + ) { + throw invalidProtocolFrame('Invalid Session mailbox Turn identity'); + } + const result = { + messageId: requireEntityId(record.messageId, 'messageId'), + targetSessionId: requireEntityId(record.targetSessionId, 'targetSessionId'), + disposition: record.disposition, + ...(record.turnId === undefined ? {} : { turnId: requireEntityId(record.turnId, 'turnId') }), + }; + return result as SessionMailboxSendResult; +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index ef0f62ba3d..fd2f23507c 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -38,6 +38,7 @@ import { import { buildToolsForAgentDefinition } from '@maka/runtime/agent-catalog'; import { buildHostCapabilitiesFromBinding } from '@maka/runtime/tool-catalog-derive'; import { buildHistoryTools } from '@maka/runtime/history-tools'; +import { buildSessionMailboxTools } from '@maka/runtime/session-mailbox-tools'; import { createLocalContinuationSafetyInspector } from '@maka/runtime/continuation-safety'; import { createConfiguredSubagentCatalog } from '@maka/runtime/configured-subagent-catalog'; import { @@ -129,6 +130,7 @@ import { } from './host-composition.js'; import { HostInteractionCoordinator } from './interaction-coordinator.js'; import { HostInteractiveTurnCoordinator } from './interactive-turn-coordinator.js'; +import { HostSessionMailboxCoordinator } from './session-mailbox-coordinator.js'; import { ensureBootstrapRuntimePolicy } from './bootstrap-runtime-policy.js'; import { hostedExecutionRunProfile } from './hosted-execution-tool-profile.js'; import { HostMemoryCoordinator } from './memory-coordinator.js'; @@ -280,6 +282,7 @@ export async function createExecutionRuntimeHostComposition( let runtimeResources: HostRuntimeResourceCoordinator | undefined; let continuity: SessionContinuityCoordinator | undefined; let manager: SessionManager | undefined; + let sessionMailbox: HostSessionMailboxCoordinator | undefined; let graphCoordinator: AgentGraphCoordinator | undefined; let graphSupervisorWake: AgentGraphSupervisorWakeCoordinator | undefined; const graphWakeActivities = new SessionActivityRegistry(); @@ -385,7 +388,11 @@ export async function createExecutionRuntimeHostComposition( createHostWebFetchToolFromService(webFetchService), ...runtimePolicy.modelTools, ]; - const hostTools = [...childHostTools, ...historyTools]; + const sessionMailboxTools = buildSessionMailboxTools({ + list: (sourceSessionId) => requireSessionMailbox(sessionMailbox).listTargets(sourceSessionId), + send: (input) => requireSessionMailbox(sessionMailbox).sendFromSession(input), + }); + const hostTools = [...childHostTools, ...historyTools, ...sessionMailboxTools]; const childAgentTools = createHostChildAgentToolComposition({ taskLedger, builtinTools, @@ -451,7 +458,9 @@ export async function createExecutionRuntimeHostComposition( const projects = new HostProjectCatalogCoordinator( openedProjectCatalog, { publish: () => hostChanges.publishProjectCatalog() }, - { publish: (sessionId: string) => hostChanges.publishSessionCatalog(sessionId) }, + { + publish: (sessionId: string) => hostChanges.publishSessionCatalog(sessionId), + }, projectMembership, context.requestDrain, new HostProjectDirectoryAuthority(options.projectDirectoryRoots), @@ -499,6 +508,12 @@ export async function createExecutionRuntimeHostComposition( onProjectionChanged: (sessionId) => requireContinuity(continuity).enqueueCanonicalRefresh(sessionId), }); + sessionMailbox = new HostSessionMailboxCoordinator({ + hostEpoch: context.hostEpoch, + messages, + listSessions: () => requireSessionManager(manager).listSessions(), + sessionStore: stores.sessionStore, + }); const rootAdmissionOwner = new RootAdmissionOwner(stores.agentRunStore); const canonicalProjectionReader = new CanonicalSessionProjectionReader({ stores, @@ -1492,6 +1507,7 @@ export async function createExecutionRuntimeHostComposition( handlers: [ executionInspect.handlers, messages.handlers, + requireSessionMailbox(sessionMailbox).handlers, interactions.handlers, sessionEffectCoordinator.handlers, continuityCoordinator.handlers, @@ -1515,6 +1531,7 @@ export async function createExecutionRuntimeHostComposition( await messages.recoverPendingAfterHostRestart( recoverySessions.map((session) => session.id), ); + await requireSessionMailbox(sessionMailbox).recover(); rootRecoveryCompleted = true; }, }, @@ -1693,7 +1710,10 @@ function adaptManagedWorkspaceFilesystemWorker( async execute(input) { // Read-only operations never participate in CAS; the adapter says so // explicitly (#3484) instead of relying on an absent optional field. - const result = await worker.execute({ ...input, expectedIdentity: 'unchecked' }); + const result = await worker.execute({ + ...input, + expectedIdentity: 'unchecked', + }); switch (result.kind) { case 'read': case 'read_image': @@ -1799,6 +1819,13 @@ function requireSessionManager(manager: SessionManager | undefined): SessionMana return manager; } +function requireSessionMailbox( + coordinator: HostSessionMailboxCoordinator | undefined, +): HostSessionMailboxCoordinator { + if (!coordinator) throw new Error('Runtime Host Session mailbox coordinator is not composed'); + return coordinator; +} + function requireGraphCoordinator( coordinator: AgentGraphCoordinator | undefined, ): AgentGraphCoordinator { diff --git a/packages/runtime-host/src/server/hosted-execution-recovery.ts b/packages/runtime-host/src/server/hosted-execution-recovery.ts index d720c185d5..9a0726b394 100644 --- a/packages/runtime-host/src/server/hosted-execution-recovery.ts +++ b/packages/runtime-host/src/server/hosted-execution-recovery.ts @@ -230,6 +230,8 @@ export function requireHostedExecutionMessageContent(admission: RootTurnAdmissio export function hostedExecutionMessageOrigin(execution: RootExecutionDescriptor) { switch (execution.kind) { + case 'external_message': + return execution.origin; case 'scheduled_task': return { kind: 'scheduled_task' as const, @@ -317,6 +319,7 @@ function verifyQueueSourceMessages( owners.length !== 1 || owners[0]?.type !== 'user' || owners[0].turnId !== admission.turnId || + !isDeepStrictEqual(owners[0].origin, source.origin) || !messageContentsEqual(normalizeMessageContent(owners[0]), source.content) ) { throw new Error( diff --git a/packages/runtime-host/src/server/message-coordinator.ts b/packages/runtime-host/src/server/message-coordinator.ts index 066d3d5e0c..c662c94f66 100644 --- a/packages/runtime-host/src/server/message-coordinator.ts +++ b/packages/runtime-host/src/server/message-coordinator.ts @@ -28,6 +28,7 @@ import { type MessageContent, } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { decodeTurnOrigin, type TurnOrigin } from '@maka/core/turn-origin'; import { RuntimeMessageAuthorityInvariantError, type RuntimeMessageAuthority, @@ -199,6 +200,7 @@ export type CandidateSnapshotPreflight = ( interface LiveEntry { readonly entryId: string; readonly messageId: string; + readonly origin?: TurnOrigin; readonly turnId: string; readonly runId: string; readonly admittedAt: number; @@ -225,6 +227,7 @@ interface PendingInterrupt { interface PendingSubmit { readonly payload: CanonicalSubmitPayload; + readonly origin?: TurnOrigin; readonly result: Promise>; } @@ -357,6 +360,56 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { this.#preflightSessionSnapshot = options.preflightSessionSnapshot; } + /** Internal Host-only admission path carrying provenance unavailable on the wire. */ + submitTrusted( + input: TurnMessageSubmitInput, + context: ConnectionContext, + origin: TurnOrigin, + ): Promise> { + return this.submit(input, context, origin); + } + + /** + * Rebuild a previously returned submit outcome from durable Host facts. + * `undefined` means no admission was ever committed and a retry is safe. + */ + async reconcileTrustedSubmit( + input: TurnMessageSubmitInput, + origin: TurnOrigin, + ): Promise | undefined> { + const payload = canonicalSubmitPayload(input, origin); + if (input.originHostEpoch === this.#hostEpoch) { + const completed = await this.#readCompletedSubmit(input.sessionId, input.messageId); + if (completed) { + return samePayload(completed.payload, payload) + ? success(completed.result) + : failure('operation_conflict', 'Message identity has a different payload'); + } + } + const durable = await this.#durableProof.readRootTurnSourceMessageReceipt( + input.sessionId, + input.messageId, + ); + if (durable) { + if (!sameSourcePayload(durable, payload)) { + return failure('operation_conflict', 'Durable message identity has different provenance'); + } + return durable.sourceMessage.disposition === 'turn_started' + ? success({ disposition: 'turn_started', turnId: durable.admission.turnId }) + : success({ disposition: 'followup', queueRevision: 0 }); + } + const pending = await this.#admissions.readMessageAdmission(input.sessionId, input.messageId); + if (!pending) return undefined; + if ( + pending.submittedContentDigest !== messageContentDigest(payload.content) || + pending.submittedPlacement !== input.placement || + !isDeepStrictEqual(pending.origin, origin) + ) { + return failure('operation_conflict', 'Pending message identity has different provenance'); + } + return success({ disposition: 'followup', queueRevision: 0 }); + } + projection(sessionId: string): SessionMessageQueueProjection { const state = this.#sessions.get(sessionId); if (!state) { @@ -687,6 +740,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { const entry: LiveEntry = { entryId: this.#createId(), messageId: admission.messageId, + ...(admission.origin ? { origin: admission.origin } : {}), turnId: admission.turnId, runId: admission.runId, admittedAt: admission.admittedAt, @@ -730,13 +784,14 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { private submit( input: TurnMessageSubmitInput, context: ConnectionContext, + origin?: TurnOrigin, ): Promise> { - const payload = canonicalSubmitPayload(input); + const payload = canonicalSubmitPayload(input, origin); const isCurrentEpoch = input.originHostEpoch === this.#hostEpoch; if (isCurrentEpoch) { const pending = this.#pendingSubmits.get(operationKey(input.sessionId, input.messageId)); if (pending) { - return samePayload(pending.payload, payload) + return samePayload(pending.payload, payload) && isDeepStrictEqual(pending.origin, origin) ? pending.result : Promise.resolve( failure('operation_conflict', 'Message identity has a different payload'), @@ -746,10 +801,10 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { if (this.#failStopped) { return Promise.resolve(failure('host_draining', 'Runtime Host message authority has failed')); } - if (!isCurrentEpoch) return this.#submitAdmitted(input, payload, context.connectionId); + if (!isCurrentEpoch) return this.#submitAdmitted(input, payload, context.connectionId, origin); const key = operationKey(input.sessionId, input.messageId); - const result = this.#submitAdmitted(input, payload, context.connectionId); - this.#pendingSubmits.set(key, { payload, result }); + const result = this.#submitAdmitted(input, payload, context.connectionId, origin); + this.#pendingSubmits.set(key, { payload, ...(origin ? { origin } : {}), result }); void result.then( () => this.#deletePendingSubmit(key, result), () => this.#deletePendingSubmit(key, result), @@ -761,6 +816,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { input: TurnMessageSubmitInput, payload: CanonicalSubmitPayload, initiatingConnectionId: string, + origin?: TurnOrigin, ): Promise> { return this.#sessionAdmission.run(input.sessionId, async (admission) => { if (this.#failStopped) { @@ -816,6 +872,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { } const sourceMessage: RootTurnSourceMessage = { messageId: input.messageId, + ...(origin ? { origin } : {}), content: payload.content, submittedContentDigest: messageContentDigest(payload.content), placement: input.placement, @@ -828,7 +885,8 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { if ( pendingAdmission && (pendingAdmission.submittedContentDigest !== messageContentDigest(payload.content) || - pendingAdmission.submittedPlacement !== input.placement) + pendingAdmission.submittedPlacement !== input.placement || + !isDeepStrictEqual(pendingAdmission.origin, origin)) ) { return failure('operation_conflict', 'Message admission has a different payload'); } @@ -850,6 +908,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { turnId, runId, messageId: input.messageId, + ...(origin ? { origin } : {}), content: canonicalContent, submittedContentDigest: messageContentDigest(payload.content), submittedPlacement: input.placement, @@ -940,6 +999,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { ), { messageId: input.messageId, + ...(origin ? { origin } : {}), content: prepared.content, submittedContentDigest: messageContentDigest(payload.content), placement: input.placement, @@ -967,6 +1027,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { turnId: rootState.turnId, runId: rootState.runId, messageId: input.messageId, + ...(origin ? { origin } : {}), content: prepared.content, submittedContentDigest: messageContentDigest(payload.content), submittedPlacement: input.placement, @@ -979,6 +1040,7 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { const entry: LiveEntry = { entryId, messageId: input.messageId, + ...(origin ? { origin } : {}), turnId: rootState.turnId, runId: rootState.runId, admittedAt: messageAdmission.admittedAt, @@ -1654,9 +1716,22 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority { ); if (!receipt) return undefined; try { + if ( + !receipt.payload || + typeof receipt.payload !== 'object' || + Array.isArray(receipt.payload) + ) { + throw new Error('payload must be an object'); + } + const { origin: rawOrigin, ...wirePayload } = receipt.payload as Record; + const origin = rawOrigin === undefined ? undefined : decodeTurnOrigin(rawOrigin); + if (rawOrigin !== undefined && origin === undefined) { + throw new Error('payload origin is malformed'); + } return { payload: canonicalSubmitPayload( - MESSAGE_OPERATION_SPECS['turn.message.submit'].decodeInput(receipt.payload), + MESSAGE_OPERATION_SPECS['turn.message.submit'].decodeInput(wirePayload), + origin, ), result: MESSAGE_OPERATION_SPECS['turn.message.submit'].decodeOutput(receipt.result), }; @@ -2091,13 +2166,15 @@ function sameSourcePayload( (durableDigest ? durableDigest === messageContentDigest(input.content) : messageContentsEqual(source.content, input.content)) && - source.placement === input.placement + source.placement === input.placement && + isDeepStrictEqual(source.origin, input.origin) ); } function sourceFromEntry(entry: LiveEntry): RootFollowupSource { return { messageId: entry.messageId, + ...(entry.origin ? { origin: entry.origin } : {}), content: normalizeMessageContent(entry.modelContent), submittedContentDigest: entry.submittedContentDigest, placement: entry.placement, @@ -2108,6 +2185,7 @@ function sourceFromEntry(entry: LiveEntry): RootFollowupSource { function pendingMessageSource(admission: PendingMessageAdmission): RootTurnSourceMessage { return { messageId: admission.messageId, + ...(admission.origin ? { origin: admission.origin } : {}), content: normalizeMessageContent(admission.content), submittedContentDigest: admission.submittedContentDigest, placement: admission.placement, @@ -2222,15 +2300,20 @@ interface CanonicalSubmitPayload { readonly messageId: string; readonly content: MessageContent; readonly placement: MessagePlacement; + readonly origin?: TurnOrigin; } -function canonicalSubmitPayload(input: TurnMessageSubmitInput): CanonicalSubmitPayload { +function canonicalSubmitPayload( + input: TurnMessageSubmitInput, + origin?: TurnOrigin, +): CanonicalSubmitPayload { return { originHostEpoch: input.originHostEpoch, sessionId: input.sessionId, messageId: input.messageId, content: normalizeMessageContent(input.content), placement: input.placement, + ...(origin ? { origin } : {}), }; } diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index 208befbb28..84b7bf1ce7 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -115,12 +115,14 @@ export type SessionRetirementOperationKey = Extract< 'session.lifecycle.set' | 'session.remove' >; export type SessionEffectOperationKey = Extract; +export type SessionMailboxOperationKey = Extract; export type SessionCatalogOperationKey = Exclude< Extract, | SessionContinuityOperationKey | SessionRevisionOperationKey | SessionRetirementOperationKey | SessionEffectOperationKey + | SessionMailboxOperationKey >; export type TaskLedgerOperationKey = Extract; export type ArtifactOperationKey = Extract; @@ -180,6 +182,10 @@ export type SessionRetirementOperationHandlerMap = Pick< SessionRetirementOperationKey >; export type SessionEffectOperationHandlerMap = Pick; +export type SessionMailboxOperationHandlerMap = Pick< + OperationHandlerMap, + SessionMailboxOperationKey +>; export type TaskLedgerOperationHandlerMap = Pick; export type ArtifactOperationHandlerMap = Pick; export type SkillCatalogOperationHandlerMap = Pick; diff --git a/packages/runtime-host/src/server/root-admission-owner.ts b/packages/runtime-host/src/server/root-admission-owner.ts index b199fb930b..4b68f35f93 100644 --- a/packages/runtime-host/src/server/root-admission-owner.ts +++ b/packages/runtime-host/src/server/root-admission-owner.ts @@ -132,6 +132,7 @@ function sameRootAdmission(left: RootTurnAdmission, right: RootTurnAdmission): b source.messageId === other.messageId && source.placement === other.placement && source.disposition === other.disposition && + isDeepStrictEqual(source.origin, other.origin) && source.submittedContentDigest === other.submittedContentDigest && messageContentsEqual(source.content, other.content) ); diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 9e36149c13..230cff6cd2 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -1090,6 +1090,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { execution: { kind: 'external_message', inputDigest: messageContentDigest(content), + ...(input.sourceMessage.origin ? { origin: input.sourceMessage.origin } : {}), }, normalizedInput: canonicalContent.content, sourceMessages: [ @@ -1159,6 +1160,9 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { execution: { kind: 'external_message', inputDigest: messageContentDigest(input.submittedContent), + ...(input.sources.length === 1 && input.sources[0]?.origin + ? { origin: input.sources[0].origin } + : {}), }, normalizedInput: input.content, sourceMessages: input.sources, @@ -2477,6 +2481,9 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { execution: { kind: 'external_message', inputDigest: messageContentDigest(batch.submittedContent), + ...(batch.sources.length === 1 && batch.sources[0]?.origin + ? { origin: batch.sources[0].origin } + : {}), }, normalizedInput: batch.content, sourceMessages: batch.sources, diff --git a/packages/runtime-host/src/server/session-mailbox-coordinator.ts b/packages/runtime-host/src/server/session-mailbox-coordinator.ts new file mode 100644 index 0000000000..dacc3c2cee --- /dev/null +++ b/packages/runtime-host/src/server/session-mailbox-coordinator.ts @@ -0,0 +1,568 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { isSideConversationSession } from '@maka/core/side-conversation'; +import { + isLinkedSubagentSession, + type SessionSummary, + type StoredMessage, +} from '@maka/core/session'; +import { + parseSessionMailboxFailedNoteData, + sessionMailboxMessageContent, + sessionMailboxFailedReceiptId, + sessionMailboxOutboxAttemptId, + sessionMailboxSentReceiptId, + sessionMailboxTurnOrigin, + parseSessionMailboxOutboxNoteData, + parseSessionMailboxSentNoteData, + type SessionMailboxKind, + type SessionMailboxFailedNoteData, + type SessionMailboxOutboxNoteData, + type SessionMailboxSentNoteData, +} from '@maka/core/session-mailbox'; +import type { SessionStore } from '@maka/storage/session-store'; +import type { + SessionMailboxSendInput, + SessionMailboxSendResult, + SessionMailboxTarget, +} from '../protocol/index.js'; +import { SESSION_MAILBOX_TARGET_MAX_ITEMS } from '../protocol/index.js'; +import type { HostMessageCoordinator } from './message-coordinator.js'; +import type { + ConnectionContext, + SessionMailboxOperationHandlerMap, +} from './operation-dispatcher.js'; + +type MailboxErrorCode = + | 'host_not_ready' + | 'host_draining' + | 'operation_unavailable' + | 'not_found' + | 'session_archived' + | 'session_busy' + | 'operation_conflict' + | 'invalid_request' + | 'outcome_unknown' + | 'internal_failure'; + +const MAILBOX_ERROR_CODES = new Set([ + 'host_not_ready', + 'host_draining', + 'operation_unavailable', + 'not_found', + 'session_archived', + 'session_busy', + 'operation_conflict', + 'invalid_request', + 'outcome_unknown', + 'internal_failure', +]); + +type MailboxOutcome = + | { readonly ok: true; readonly result: T } + | { + readonly ok: false; + readonly error: { + readonly code: MailboxErrorCode; + readonly message: string; + }; + }; + +export interface HostSessionMailboxCoordinatorOptions { + readonly hostEpoch: string; + readonly messages: Pick; + readonly listSessions: () => Promise; + readonly sessionStore: Pick; + readonly createId?: () => string; + readonly now?: () => number; +} + +/** Host-owned routing between ordinary root Sessions in one project. */ +export class HostSessionMailboxCoordinator { + readonly handlers: SessionMailboxOperationHandlerMap = { + 'session.mailbox.targets': (input) => this.#targets(input.sourceSessionId), + 'session.mailbox.send': (input, context) => this.#send(input, context.connectionId), + }; + + readonly #hostEpoch: string; + readonly #messages: Pick; + readonly #listSessions: () => Promise; + readonly #sessionStore: Pick; + readonly #createId: () => string; + readonly #now: () => number; + + constructor(options: HostSessionMailboxCoordinatorOptions) { + this.#hostEpoch = options.hostEpoch; + this.#messages = options.messages; + this.#listSessions = options.listSessions; + this.#sessionStore = options.sessionStore; + this.#createId = options.createId ?? randomUUID; + this.#now = options.now ?? Date.now; + } + + async listTargets(sourceSessionId: string): Promise { + const outcome = await this.#targets(sourceSessionId); + if (!outcome.ok) throw new Error(outcome.error.message); + return outcome.result.targets; + } + + async sendFromSession(input: { + readonly sourceSessionId: string; + readonly targetSessionId: string; + readonly kind: SessionMailboxKind; + readonly text: string; + readonly correlationId?: string; + }): Promise { + const outcome = await this.#send( + { ...input, messageId: this.#createId() }, + `session-mailbox:${input.sourceSessionId}`, + ); + if (!outcome.ok) throw new Error(outcome.error.message); + return outcome.result; + } + + /** Repair durable sender outboxes after a Host restart. */ + async recover(): Promise { + const sessions = await this.#listSessions(); + const byId = new Map(sessions.map((session) => [session.id, session])); + for (const source of sessions) { + if (!isMailboxRoot(source)) continue; + let messages: readonly StoredMessage[]; + try { + messages = await this.#sessionStore.readMessagesSnapshot(source.id); + } catch { + continue; + } + const settled = new Set(); + for (const message of messages) { + if (message.type !== 'system_note') continue; + if (message.kind === 'session_mailbox_sent') { + const receipt = parseSessionMailboxSentNoteData(message.data); + if (receipt) settled.add(receipt.messageId); + } else if (message.kind === 'session_mailbox_failed') { + const rejection = parseSessionMailboxFailedNoteData(message.data); + if (rejection) settled.add(rejection.messageId); + } + } + const pending = new Map(); + for (const message of messages) { + if (message.type !== 'system_note' || message.kind !== 'session_mailbox_outbox') continue; + const outbox = parseSessionMailboxOutboxNoteData(message.data); + if (outbox && !settled.has(outbox.messageId)) pending.set(outbox.messageId, outbox); + } + for (const outbox of pending.values()) { + if (!byId.has(outbox.toSessionId)) continue; + await this.#deliverOutbox(outbox, `session-mailbox-recovery:${source.id}`, true).catch( + () => undefined, + ); + } + } + } + + async #targets( + sourceSessionId: string, + ): Promise> { + const sessions = await this.#listSessions(); + const source = sessions.find((session) => session.id === sourceSessionId); + if (!source || !isMailboxRoot(source)) { + return failure('not_found', 'Source Session is not available for Session messaging'); + } + if (source.isArchived) return failure('session_archived', 'Source Session is archived'); + const targets = sessions + .filter( + (candidate) => + candidate.id !== source.id && + !candidate.isArchived && + isMailboxRoot(candidate) && + sharesProject(source, candidate), + ) + .map(toMailboxTarget) + .slice(0, SESSION_MAILBOX_TARGET_MAX_ITEMS); + return success({ targets }); + } + + async #send( + input: SessionMailboxSendInput, + initiatingConnectionId: string, + ): Promise> { + if (input.sourceSessionId === input.targetSessionId) { + return failure('invalid_request', 'A Session cannot send a message to itself'); + } + const sessions = await this.#listSessions(); + const source = sessions.find((session) => session.id === input.sourceSessionId); + const target = sessions.find((session) => session.id === input.targetSessionId); + if (!source || !isMailboxRoot(source)) { + return failure('not_found', 'Source Session is not available for Session messaging'); + } + if (source.isArchived) return failure('session_archived', 'Source Session is archived'); + if (!target || target.isArchived || !isMailboxRoot(target) || !sharesProject(source, target)) { + return failure('not_found', 'Target Session is not reachable from this Session'); + } + const outbox: SessionMailboxOutboxNoteData = { + originHostEpoch: this.#hostEpoch, + messageId: input.messageId, + fromSessionId: source.id, + fromSessionName: source.name, + toSessionId: target.id, + targetSessionName: target.name, + kind: input.kind, + text: input.text, + ...(input.correlationId ? { correlationId: input.correlationId } : {}), + }; + const existing = await this.#readExistingResult(source.id, outbox); + if (existing) return existing; + await this.#persistOutboxAttempt(source.id, outbox); + return this.#deliverOutbox(outbox, initiatingConnectionId, false); + } + + async #deliverOutbox( + initialOutbox: SessionMailboxOutboxNoteData, + initiatingConnectionId: string, + reconcileFirst: boolean, + ): Promise> { + let outbox = initialOutbox; + let submitted = reconcileFirst + ? await this.#messages.reconcileTrustedSubmit( + submitInput(outbox), + sessionMailboxTurnOrigin(outbox), + ) + : undefined; + if (submitted === undefined) { + if (outbox.originHostEpoch !== this.#hostEpoch) { + outbox = { ...outbox, originHostEpoch: this.#hostEpoch }; + await this.#persistOutboxAttempt(outbox.fromSessionId, outbox); + } + submitted = await this.#messages.submitTrusted( + submitInput(outbox), + connectionContext(this.#hostEpoch, initiatingConnectionId), + sessionMailboxTurnOrigin(outbox), + ); + } + if (!submitted.ok) { + if (submitted.error.code !== 'outcome_unknown') { + await this.#recordFailedMessage(outbox.fromSessionId, { + ...outbox, + errorCode: submitted.error.code, + errorMessage: submitted.error.message, + }); + } + return failure(submitted.error.code, submitted.error.message); + } + const disposition = submitted.result.disposition === 'turn_started' ? 'turn_started' : 'queued'; + const receipt: SessionMailboxSentNoteData = { + messageId: outbox.messageId, + targetSessionId: outbox.toSessionId, + targetSessionName: outbox.targetSessionName, + kind: outbox.kind, + text: outbox.text, + ...(outbox.correlationId ? { correlationId: outbox.correlationId } : {}), + disposition, + ...(submitted.result.disposition === 'turn_started' + ? { turnId: submitted.result.turnId } + : {}), + }; + try { + await this.#recordSentMessage(outbox.fromSessionId, receipt); + } catch { + // The pre-admission outbox remains durable. Startup recovery replays the + // Host receipt/proof and repairs this settlement without redelivery. + } + return success({ + messageId: outbox.messageId, + targetSessionId: outbox.toSessionId, + disposition, + ...(submitted.result.disposition === 'turn_started' + ? { turnId: submitted.result.turnId } + : {}), + }); + } + + async #readExistingResult( + sourceSessionId: string, + outbox: SessionMailboxOutboxNoteData, + ): Promise | undefined> { + const messages = await this.#sessionStore.readMessagesSnapshot(sourceSessionId); + const receiptMessage = messages.find( + (message) => message.id === sessionMailboxSentReceiptId(outbox.messageId), + ); + const failedMessage = messages.find( + (message) => message.id === sessionMailboxFailedReceiptId(outbox.messageId), + ); + if (receiptMessage && failedMessage) { + return failure( + 'operation_conflict', + 'Mailbox message identity has multiple terminal results', + ); + } + if (receiptMessage) { + const receipt = + receiptMessage.type === 'system_note' + ? parseSessionMailboxSentNoteData(receiptMessage.data) + : undefined; + if (!receipt || !receiptMatchesOutbox(receipt, outbox)) { + return failure('operation_conflict', 'Mailbox message identity has different durable data'); + } + return success({ + messageId: receipt.messageId, + targetSessionId: receipt.targetSessionId, + disposition: receipt.disposition, + ...(receipt.turnId ? { turnId: receipt.turnId } : {}), + }); + } + if (!failedMessage) return undefined; + const rejection = + failedMessage.type === 'system_note' + ? parseSessionMailboxFailedNoteData(failedMessage.data) + : undefined; + if ( + !rejection || + !failureMatchesOutbox(rejection, outbox) || + !isMailboxErrorCode(rejection.errorCode) + ) { + return failure('operation_conflict', 'Mailbox message identity has different durable data'); + } + return failure(rejection.errorCode, rejection.errorMessage); + } + + async #persistOutboxAttempt( + sourceSessionId: string, + data: SessionMailboxOutboxNoteData, + ): Promise { + const id = sessionMailboxOutboxAttemptId(data.messageId, data.originHostEpoch); + const messages = await this.#sessionStore.readMessagesSnapshot(sourceSessionId); + const existing = messages.find((message) => message.id === id); + if (existing) { + const persisted = + existing.type === 'system_note' + ? parseSessionMailboxOutboxNoteData(existing.data) + : undefined; + if (!persisted || !outboxDataEqual(persisted, data)) { + throw new Error('Mailbox outbox identity has different durable data'); + } + return; + } + const anchorTurnId = latestTurnId(messages); + await this.#sessionStore.appendMessage(sourceSessionId, { + type: 'system_note', + id, + ...(anchorTurnId ? { turnId: anchorTurnId } : {}), + ts: this.#now(), + kind: 'session_mailbox_outbox', + data, + }); + } + + async #recordSentMessage( + sourceSessionId: string, + data: SessionMailboxSentNoteData, + ): Promise { + const receiptId = sessionMailboxSentReceiptId(data.messageId); + const messages = await this.#sessionStore.readMessagesSnapshot(sourceSessionId); + const existing = messages.find((message) => message.id === receiptId); + if (existing) { + const persisted = + existing.type === 'system_note' + ? parseSessionMailboxSentNoteData(existing.data) + : undefined; + if (!persisted || !sentDataEqual(persisted, data)) { + throw new Error('Mailbox receipt identity has different durable data'); + } + return; + } + const anchorTurnId = latestTurnId(messages); + await this.#sessionStore.appendMessage(sourceSessionId, { + type: 'system_note', + id: receiptId, + ...(anchorTurnId ? { turnId: anchorTurnId } : {}), + ts: this.#now(), + kind: 'session_mailbox_sent', + data, + }); + } + + async #recordFailedMessage( + sourceSessionId: string, + data: SessionMailboxFailedNoteData, + ): Promise { + const receiptId = sessionMailboxFailedReceiptId(data.messageId); + const messages = await this.#sessionStore.readMessagesSnapshot(sourceSessionId); + const existing = messages.find((message) => message.id === receiptId); + if (existing) { + const persisted = + existing.type === 'system_note' + ? parseSessionMailboxFailedNoteData(existing.data) + : undefined; + if (!persisted || !failedDataEqual(persisted, data)) { + throw new Error('Mailbox failure identity has different durable data'); + } + return; + } + const anchorTurnId = latestTurnId(messages); + await this.#sessionStore.appendMessage(sourceSessionId, { + type: 'system_note', + id: receiptId, + ...(anchorTurnId ? { turnId: anchorTurnId } : {}), + ts: this.#now(), + kind: 'session_mailbox_failed', + data, + }); + } +} + +function latestTurnId(messages: readonly StoredMessage[]): string | undefined { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const turnId = messages[index]?.turnId; + if (turnId) return turnId; + } + return undefined; +} + +function isMailboxRoot(session: SessionSummary): boolean { + return !isLinkedSubagentSession(session) && !isSideConversationSession(session.labels); +} + +function sharesProject(source: SessionSummary, target: SessionSummary): boolean { + if (source.projectId && target.projectId) return source.projectId === target.projectId; + return source.cwd !== undefined && source.cwd === target.cwd; +} + +function submitInput(outbox: SessionMailboxOutboxNoteData) { + return { + originHostEpoch: outbox.originHostEpoch, + sessionId: outbox.toSessionId, + messageId: outbox.messageId, + content: sessionMailboxMessageContent(outbox), + placement: 'next_turn' as const, + }; +} + +function receiptMatchesOutbox( + receipt: SessionMailboxSentNoteData, + outbox: SessionMailboxOutboxNoteData, +): boolean { + return ( + receipt.messageId === outbox.messageId && + receipt.targetSessionId === outbox.toSessionId && + receipt.targetSessionName === outbox.targetSessionName && + receipt.kind === outbox.kind && + receipt.text === outbox.text && + receipt.correlationId === outbox.correlationId + ); +} + +function sentDataEqual( + left: SessionMailboxSentNoteData, + right: SessionMailboxSentNoteData, +): boolean { + return ( + receiptMatchesOutbox(left, { + originHostEpoch: '', + messageId: right.messageId, + fromSessionId: '', + fromSessionName: '', + toSessionId: right.targetSessionId, + targetSessionName: right.targetSessionName, + kind: right.kind, + text: right.text, + ...(right.correlationId ? { correlationId: right.correlationId } : {}), + }) && + left.disposition === right.disposition && + left.turnId === right.turnId + ); +} + +function failureMatchesOutbox( + failureData: SessionMailboxFailedNoteData, + outbox: SessionMailboxOutboxNoteData, +): boolean { + return ( + failureData.messageId === outbox.messageId && + failureData.fromSessionId === outbox.fromSessionId && + failureData.fromSessionName === outbox.fromSessionName && + failureData.toSessionId === outbox.toSessionId && + failureData.targetSessionName === outbox.targetSessionName && + failureData.kind === outbox.kind && + failureData.text === outbox.text && + failureData.correlationId === outbox.correlationId + ); +} + +function failedDataEqual( + left: SessionMailboxFailedNoteData, + right: SessionMailboxFailedNoteData, +): boolean { + return ( + outboxDataEqual(left, right) && + left.errorCode === right.errorCode && + left.errorMessage === right.errorMessage + ); +} + +function isMailboxErrorCode(value: string): value is MailboxErrorCode { + return MAILBOX_ERROR_CODES.has(value as MailboxErrorCode); +} + +function outboxDataEqual( + left: SessionMailboxOutboxNoteData, + right: SessionMailboxOutboxNoteData, +): boolean { + return ( + left.originHostEpoch === right.originHostEpoch && + left.messageId === right.messageId && + left.fromSessionId === right.fromSessionId && + left.fromSessionName === right.fromSessionName && + left.toSessionId === right.toSessionId && + left.targetSessionName === right.targetSessionName && + left.kind === right.kind && + left.text === right.text && + left.correlationId === right.correlationId + ); +} + +function toMailboxTarget(session: SessionSummary): SessionMailboxTarget { + return { + sessionId: session.id, + name: session.name, + status: + session.status === 'waiting_for_user' + ? 'waiting_for_user' + : (session.runningTurnIds?.length ?? 0) > 0 + ? 'running' + : 'idle', + }; +} + +function connectionContext(hostEpoch: string, connectionId: string): ConnectionContext { + return { + hostEpoch, + connectionId, + principal: 'runtime_host', + acquireResidency: () => ({ release: () => undefined }), + }; +} + +function success(result: T): MailboxOutcome { + return { ok: true, result }; +} + +function failure(code: MailboxErrorCode, message: string): MailboxOutcome { + return { ok: false, error: { code, message } }; +} diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 53d05f563c..6c678c74f2 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -20,6 +20,7 @@ "./test-connection": "./dist/test-connection.js", "./model-fetcher": "./dist/model-fetcher.js", "./session-manager": "./dist/session-manager.js", + "./session-mailbox-tools": "./dist/session-mailbox-tools.js", "./test-only/fake-backend": "./dist/test-only/fake-backend.js", "./test-only/observation-text-reader": "./dist/__tests__/observation-text-reader.js", "./filesystem-worker": "./dist/filesystem-worker/index.js", diff --git a/packages/runtime/src/__tests__/session-mailbox-tools.test.ts b/packages/runtime/src/__tests__/session-mailbox-tools.test.ts new file mode 100644 index 0000000000..1c2519d930 --- /dev/null +++ b/packages/runtime/src/__tests__/session-mailbox-tools.test.ts @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + SESSION_LIST_TOOL_NAME, + SESSION_REPLY_TOOL_NAME, + SESSION_SEND_TOOL_NAME, + buildSessionMailboxTools, +} from '../session-mailbox-tools.js'; +import type { MakaTool, MakaToolContext } from '../tool-runtime.js'; + +test('Session mailbox tools bind the current Session as the source', async () => { + const sends: unknown[] = []; + const tools = buildSessionMailboxTools({ + list: async () => [{ sessionId: 'target-1', name: 'Target', status: 'idle' }], + send: async (input) => { + sends.push(input); + return { + messageId: 'message-1', + targetSessionId: input.targetSessionId, + disposition: 'queued', + }; + }, + }); + + assert.match(String(await tool(tools, SESSION_LIST_TOOL_NAME).impl({}, context())), /target-1/); + await tool(tools, SESSION_SEND_TOOL_NAME).impl( + { target_session_id: 'target-1', text: 'Please check', kind: 'request' }, + context(), + ); + await tool(tools, SESSION_REPLY_TOOL_NAME).impl( + { target_session_id: 'target-1', in_reply_to: 'request-1', text: 'Done' }, + context(), + ); + + assert.deepEqual(sends, [ + { + sourceSessionId: 'source-1', + targetSessionId: 'target-1', + kind: 'request', + text: 'Please check', + }, + { + sourceSessionId: 'source-1', + targetSessionId: 'target-1', + kind: 'reply', + text: 'Done', + correlationId: 'request-1', + }, + ]); +}); + +function tool(tools: MakaTool[], name: string): MakaTool { + const found = tools.find((candidate) => candidate.name === name); + assert.ok(found); + return found; +} + +function context(): MakaToolContext { + return { + sessionId: 'source-1', + turnId: 'turn-1', + cwd: '/workspace', + toolCallId: 'call-1', + abortSignal: new AbortController().signal, + emitOutput: () => undefined, + }; +} diff --git a/packages/runtime/src/session-mailbox-tools.ts b/packages/runtime/src/session-mailbox-tools.ts new file mode 100644 index 0000000000..082c7abf49 --- /dev/null +++ b/packages/runtime/src/session-mailbox-tools.ts @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { z } from 'zod'; +import { SESSION_MAILBOX_TEXT_MAX_BYTES } from '@maka/core/session-mailbox'; +import type { MakaTool } from './tool-runtime.js'; + +export const SESSION_LIST_TOOL_NAME = 'session_list'; +export const SESSION_SEND_TOOL_NAME = 'session_send'; +export const SESSION_REPLY_TOOL_NAME = 'session_reply'; + +export interface SessionMailboxToolTarget { + readonly sessionId: string; + readonly name: string; + readonly status: 'idle' | 'running' | 'waiting_for_user'; +} + +export interface SessionMailboxToolSendResult { + readonly messageId: string; + readonly targetSessionId: string; + readonly disposition: 'turn_started' | 'queued'; + readonly turnId?: string; +} + +export interface SessionMailboxToolAuthority { + list(sourceSessionId: string): Promise; + send(input: { + sourceSessionId: string; + targetSessionId: string; + kind: 'request' | 'reply' | 'notification'; + text: string; + correlationId?: string; + }): Promise; +} + +const targetSchema = z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9_-]+$/) + .describe(`Target Session id returned by ${SESSION_LIST_TOOL_NAME}.`); +const textSchema = z + .string() + .trim() + .min(1) + .refine((value) => new TextEncoder().encode(value).byteLength <= SESSION_MAILBOX_TEXT_MAX_BYTES, { + message: `Session message exceeds ${SESSION_MAILBOX_TEXT_MAX_BYTES} UTF-8 bytes`, + }); + +export function buildSessionMailboxTools(authority: SessionMailboxToolAuthority): MakaTool[] { + return [ + { + name: SESSION_LIST_TOOL_NAME, + displayName: 'Session List', + description: 'List other user Sessions in the same project that this Session may contact.', + parameters: z.object({}), + impl: async (_input, ctx) => { + const targets = await authority.list(ctx.sessionId); + return targets.length === 0 + ? 'No reachable Sessions.' + : targets + .map((target) => `- ${target.sessionId} | ${target.name} | ${target.status}`) + .join('\n'); + }, + }, + { + name: SESSION_SEND_TOOL_NAME, + displayName: 'Session Send', + description: + 'Send a durable message to another Session in the same project. Requests ask the target ' + + `to process the message and reply with ${SESSION_REPLY_TOOL_NAME}; notifications do not require a reply.`, + parameters: z.object({ + target_session_id: targetSchema, + text: textSchema, + kind: z.enum(['request', 'notification']).default('request'), + }), + impl: async (input, ctx) => + formatDelivery( + await authority.send({ + sourceSessionId: ctx.sessionId, + targetSessionId: input.target_session_id, + kind: input.kind, + text: input.text, + }), + ), + }, + { + name: SESSION_REPLY_TOOL_NAME, + displayName: 'Session Reply', + description: 'Reply to a Session request using its source Session and message identities.', + parameters: z.object({ + target_session_id: targetSchema, + in_reply_to: z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9_-]+$/), + text: textSchema, + }), + impl: async (input, ctx) => + formatDelivery( + await authority.send({ + sourceSessionId: ctx.sessionId, + targetSessionId: input.target_session_id, + kind: 'reply', + text: input.text, + correlationId: input.in_reply_to, + }), + ), + }, + ]; +} + +function formatDelivery(result: SessionMailboxToolSendResult): string { + return [ + `Session message ${result.messageId} ${result.disposition === 'queued' ? 'queued' : 'delivered'}.`, + `target=${result.targetSessionId}`, + ...(result.turnId ? [`turn=${result.turnId}`] : []), + ].join('\n'); +} diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index f939ab6099..1045bcd757 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -316,6 +316,14 @@ describe('SqliteSessionMetadataStore', () => { turnId: 'turn-1', runId: 'run-1', messageId: 'message-1', + origin: { + kind: 'session_mailbox', + messageId: 'message-1', + fromSessionId: 'source-session', + fromSessionName: 'Source session', + toSessionId: 'session-1', + mailboxKind: 'request', + }, content: { text: 'submitted', displayText: 'submitted' }, submittedContentDigest: messageContentDigest({ text: 'submitted' }), submittedPlacement: 'current_turn', @@ -353,6 +361,7 @@ describe('SqliteSessionMetadataStore', () => { turnId: message.turnId, text: message.type === 'user' ? message.text : undefined, steeringEventId: message.type === 'user' ? message.steeringEventId : undefined, + origin: message.type === 'user' ? message.origin : undefined, })), [ { @@ -361,6 +370,7 @@ describe('SqliteSessionMetadataStore', () => { turnId: 'turn-1', text: 'submitted', steeringEventId: 'message-1', + origin: admission.origin, }, ], ); diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 9b1af4fe73..dc515568f6 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -77,6 +77,8 @@ import { isTurnOrchestrationSource, type TurnOrchestration, } from '@maka/core/orchestration'; +import { decodeTurnOrigin } from '@maka/core/turn-origin'; +import type { TurnOrigin } from '@maka/core/turn-origin'; import { scanToolLedger, validateGenericToolLedgerAppend, @@ -94,6 +96,8 @@ const ROOT_TURN_ADMISSION_MAX_AGGREGATED_ATTACHMENTS = export interface RootTurnSourceMessage { messageId: string; content: MessageContent; + /** Trusted Host-authored provenance; never accepted from message protocol input. */ + origin?: TurnOrigin; submittedContentDigest?: `sha256:${string}`; placement: 'current_turn' | 'next_turn'; disposition: 'steering' | 'followup' | 'turn_started'; @@ -1652,12 +1656,21 @@ function normalizeRootTurnSourceMessages(value: unknown): readonly RootTurnSourc 'content', 'placement', 'disposition', + ...(Object.hasOwn(item, 'origin') ? ['origin'] : []), ...(Object.hasOwn(item, 'submittedContentDigest') ? ['submittedContentDigest'] : []), ]) ) { throw new Error(`Invalid root turn source message at index ${index}`); } - const { messageId, content, submittedContentDigest, placement, disposition } = item; + const { + messageId, + content, + origin: rawOrigin, + submittedContentDigest, + placement, + disposition, + } = item; + const origin = rawOrigin === undefined ? undefined : decodeTurnOrigin(rawOrigin); if ( typeof messageId !== 'string' || !isSafeId(messageId) || @@ -1667,6 +1680,7 @@ function normalizeRootTurnSourceMessages(value: unknown): readonly RootTurnSourc disposition !== 'turn_started') || (disposition === 'steering' && placement !== 'current_turn') || (disposition === 'followup' && placement !== 'next_turn') || + (rawOrigin !== undefined && origin === undefined) || (submittedContentDigest !== undefined && !isSha256Digest(submittedContentDigest)) ) { throw new Error(`Invalid root turn source message at index ${index}`); @@ -1682,6 +1696,7 @@ function normalizeRootTurnSourceMessages(value: unknown): readonly RootTurnSourc `root turn source message content at index ${index}`, MAX_ATTACHMENT_COUNT, ), + ...(origin !== undefined ? { origin: Object.freeze(origin) } : {}), ...(submittedContentDigest !== undefined ? { submittedContentDigest } : {}), placement, disposition, @@ -1709,6 +1724,7 @@ function rootTurnAdmissionPayloadsEqual( source.messageId === other.messageId && source.placement === other.placement && source.disposition === other.disposition && + isDeepStrictEqual(source.origin, other.origin) && source.submittedContentDigest === other.submittedContentDigest && messageContentsEqual(source.content, other.content) ); @@ -1874,7 +1890,7 @@ function normalizeRootExecutionDescriptor(value: unknown): RootExecutionDescript throw new Error('Invalid root execution descriptor'); } if (value.kind === 'external_message') { - const allowedKeys = ['kind', 'inputDigest', 'maxSteps']; + const allowedKeys = ['kind', 'inputDigest', 'maxSteps', 'origin']; if (!Object.keys(value).every((key) => allowedKeys.includes(key))) { throw new Error('Invalid root execution descriptor'); } @@ -1889,10 +1905,15 @@ function normalizeRootExecutionDescriptor(value: unknown): RootExecutionDescript ) { throw new Error('Invalid root execution descriptor'); } + const origin = value.origin === undefined ? undefined : decodeTurnOrigin(value.origin); + if (value.origin !== undefined && origin === undefined) { + throw new Error('Invalid root execution descriptor'); + } return Object.freeze({ kind: 'external_message', ...(value.inputDigest !== undefined ? { inputDigest: value.inputDigest } : {}), ...(value.maxSteps !== undefined ? { maxSteps: value.maxSteps } : {}), + ...(origin !== undefined ? { origin: Object.freeze(origin) } : {}), }); } if (value.kind === 'workhub_coordination') { diff --git a/packages/storage/src/message-admission-store.ts b/packages/storage/src/message-admission-store.ts index a73a3f43e2..82437a8e45 100644 --- a/packages/storage/src/message-admission-store.ts +++ b/packages/storage/src/message-admission-store.ts @@ -19,6 +19,7 @@ import { isDeepStrictEqual } from 'node:util'; import { normalizeMessageContent, type MessageContent } from '@maka/core/events'; +import { decodeTurnOrigin, type TurnOrigin } from '@maka/core/turn-origin'; const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; @@ -27,6 +28,7 @@ export interface PendingMessageAdmission { readonly turnId: string; readonly runId: string; readonly messageId: string; + readonly origin?: TurnOrigin; readonly content: MessageContent; readonly submittedContentDigest: `sha256:${string}`; readonly submittedPlacement: 'current_turn' | 'next_turn'; @@ -75,8 +77,13 @@ export function normalizePendingMessageAdmission( if (!Number.isSafeInteger(admission.admittedAt) || admission.admittedAt < 0) { throw new Error('Invalid message admission timestamp'); } + const origin = admission.origin === undefined ? undefined : decodeTurnOrigin(admission.origin); + if (admission.origin !== undefined && origin === undefined) { + throw new Error('Invalid pending Message origin'); + } const normalized = Object.freeze({ ...admission, + ...(origin ? { origin: Object.freeze(origin) } : {}), content: normalizeMessageContent(admission.content), }); if (!/^sha256:[a-f0-9]{64}$/u.test(normalized.submittedContentDigest)) { @@ -96,6 +103,7 @@ export function samePendingMessageAdmission( a.turnId === b.turnId && a.runId === b.runId && a.messageId === b.messageId && + isDeepStrictEqual(a.origin, b.origin) && a.submittedContentDigest === b.submittedContentDigest && a.submittedPlacement === b.submittedPlacement && a.placement === b.placement && diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 2cc54783af..83bb824a6d 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 31; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 32; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -1194,6 +1194,12 @@ const MIGRATIONS: ReadonlyMap = new Map([ WHERE json_extract(payload_json, '$.role') = 'workhub_coordination'; `, ], + [ + 32, + ` + ALTER TABLE message_admissions ADD COLUMN origin_json TEXT; + `, + ], ]); if (MIGRATIONS.size !== SQLITE_SESSION_METADATA_SCHEMA_VERSION) { @@ -1251,7 +1257,9 @@ export function migrateSqliteSessionMetadataDatabase( ) { const sql = MIGRATIONS.get(version); if (!sql) throw new Error(`Missing SQLite session metadata migration ${version}`); - db.exec(sql); + if (version !== 32 || !hasColumn(db, 'message_admissions', 'origin_json')) { + db.exec(sql); + } if (version === 29 && hasColumn(db, 'session_metadata', 'last_used_at')) { db.exec('ALTER TABLE session_metadata DROP COLUMN last_used_at'); } diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 8943dea71f..390e186c5c 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -240,6 +240,7 @@ interface MessageAdmissionRow { readonly turn_id?: unknown; readonly run_id?: unknown; readonly message_id?: unknown; + readonly origin_json?: unknown; readonly content_json?: unknown; readonly submitted_content_digest?: unknown; readonly submitted_placement?: unknown; @@ -257,6 +258,7 @@ function decodeMessageAdmissionRow( typeof row.turn_id !== 'string' || typeof row.run_id !== 'string' || typeof row.message_id !== 'string' || + (row.origin_json !== null && typeof row.origin_json !== 'string') || typeof row.content_json !== 'string' || typeof row.submitted_content_digest !== 'string' || (row.submitted_placement !== 'current_turn' && row.submitted_placement !== 'next_turn') || @@ -274,6 +276,9 @@ function decodeMessageAdmissionRow( turnId: row.turn_id, runId: row.run_id, messageId: row.message_id, + ...(typeof row.origin_json === 'string' + ? { origin: JSON.parse(row.origin_json) as PendingMessageAdmission['origin'] } + : {}), content: JSON.parse(row.content_json) as PendingMessageAdmission['content'], submittedContentDigest: row.submitted_content_digest as PendingMessageAdmission['submittedContentDigest'], @@ -1573,7 +1578,7 @@ export class SqliteSessionMetadataStore { const existingRow = this.db .prepare( ` - SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, + SELECT turn_id, run_id, message_id, origin_json, content_json, submitted_content_digest, submitted_placement, placement, disposition, queue_order, admitted_at FROM message_admissions WHERE session_id = ? AND message_id = ? @@ -1611,9 +1616,9 @@ export class SqliteSessionMetadataStore { .prepare( ` INSERT INTO message_admissions( - session_id, turn_id, run_id, message_id, content_json, submitted_content_digest, + session_id, turn_id, run_id, message_id, origin_json, content_json, submitted_content_digest, submitted_placement, placement, disposition, queue_order, admitted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( @@ -1621,6 +1626,7 @@ export class SqliteSessionMetadataStore { stored.turnId, stored.runId, stored.messageId, + stored.origin ? JSON.stringify(stored.origin) : null, JSON.stringify(stored.content), stored.submittedContentDigest, stored.submittedPlacement, @@ -1645,7 +1651,7 @@ export class SqliteSessionMetadataStore { const row = this.db .prepare( ` - SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, + SELECT turn_id, run_id, message_id, origin_json, content_json, submitted_content_digest, submitted_placement, placement, disposition, queue_order, admitted_at FROM message_admissions WHERE session_id = ? AND message_id = ? @@ -1663,7 +1669,7 @@ export class SqliteSessionMetadataStore { const rows = this.db .prepare( ` - SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, + SELECT turn_id, run_id, message_id, origin_json, content_json, submitted_content_digest, submitted_placement, placement, disposition, queue_order, admitted_at FROM message_admissions WHERE session_id = ? @@ -1703,7 +1709,7 @@ export class SqliteSessionMetadataStore { const admissionRow = this.db .prepare( ` - SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, + SELECT turn_id, run_id, message_id, origin_json, content_json, submitted_content_digest, submitted_placement, placement, disposition, queue_order, admitted_at FROM message_admissions WHERE session_id = ? AND message_id = ? @@ -1754,6 +1760,7 @@ export class SqliteSessionMetadataStore { ts: admission.admittedAt, ...admission.content, steeringEventId: messageId, + ...(admission.origin ? { origin: admission.origin } : {}), }); const json = JSON.stringify(message); this.insertSessionMessagesSync(input.sessionId, nextSequence, [{ message, json }]); @@ -1771,7 +1778,8 @@ export class SqliteSessionMetadataStore { message.type !== 'user' || message.id !== messageId || (admission !== undefined && - !messageContentsEqual(normalizeMessageContent(message), admission.content)) + (!messageContentsEqual(normalizeMessageContent(message), admission.content) || + !isDeepStrictEqual(message.origin, admission.origin))) ) { throw new SessionMetadataConflictError( 'Message admission transcript identity conflict', @@ -1812,7 +1820,7 @@ export class SqliteSessionMetadataStore { const currentRow = this.db .prepare( ` - SELECT turn_id, run_id, message_id, content_json, submitted_content_digest, + SELECT turn_id, run_id, message_id, origin_json, content_json, submitted_content_digest, submitted_placement, placement, disposition, queue_order, admitted_at FROM message_admissions WHERE session_id = ? AND message_id = ? @@ -1824,6 +1832,7 @@ export class SqliteSessionMetadataStore { if ( current.turnId !== stored.turnId || current.runId !== stored.runId || + !isDeepStrictEqual(current.origin, stored.origin) || current.submittedPlacement !== stored.submittedPlacement || current.admittedAt !== stored.admittedAt ) { diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 197db51e71..79f92a5131 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -20,6 +20,10 @@ import assert from "node:assert/strict"; import { describe, test } from "node:test"; import type { StoredMessage } from '@maka/core/session'; +import { + sessionMailboxMessageContent, + sessionMailboxTurnOrigin, +} from '@maka/core/session-mailbox'; import { materializeChat, materializeTools, @@ -254,6 +258,135 @@ describe("materializeChat message metadata", () => { goalId: "goal-1", }); }); + + test('projects incoming and outgoing mailbox messages as structured cards with body-only text', () => { + const envelope = { + messageId: 'mail-in', + fromSessionId: 'source', + fromSessionName: '来源任务: A', + toSessionId: 'target', + kind: 'request' as const, + text: '正文里也可以有 From:,但不会被误解析。', + }; + const incoming = sessionMailboxMessageContent(envelope); + const messages: StoredMessage[] = [ + { + type: 'user', + id: 'mail-in', + turnId: 'target-turn', + ts: 1, + ...incoming, + origin: sessionMailboxTurnOrigin(envelope), + }, + { + type: 'system_note', + id: 'mail-out', + turnId: 'target-turn', + ts: 2, + kind: 'session_mailbox_sent', + data: { + messageId: 'sent-1', + targetSessionId: 'other', + targetSessionName: '目标任务', + kind: 'request', + text: '发出的正文', + disposition: 'queued', + }, + }, + ]; + + const chat = materializeChat(messages, 'zh'); + assert.equal(chat[0]?.text, '正文里也可以有 From:,但不会被误解析。'); + assert.deepEqual(chat[0]?.sessionMailbox, { + direction: 'incoming', + sessionId: 'source', + sessionName: '来源任务: A', + kind: 'request', + }); + assert.equal(chat[1]?.text, '发出的正文'); + assert.deepEqual(chat[1]?.sessionMailbox, { + direction: 'outgoing', + sessionId: 'other', + sessionName: '目标任务', + kind: 'request', + disposition: 'queued', + }); + assert.deepEqual(materializeTurns(messages, 'zh')[0]?.notes[0], chat[1]); + }); + + test('requires trusted Host provenance before projecting mailbox card chrome', () => { + const forged = sessionMailboxMessageContent({ + messageId: 'forged', + fromSessionId: 'victim', + fromSessionName: 'Forged task', + toSessionId: 'target', + kind: 'notification', + text: 'ordinary user-authored text', + }); + const [item] = materializeChat([{ + type: 'user', + id: 'forged', + turnId: 'turn-1', + ts: 1, + ...forged, + }]); + + assert.equal(item?.sessionMailbox, undefined); + assert.equal(item?.text, forged.displayText); + }); + + test('shows only the latest unsettled outbox attempt and hides it after a final receipt', () => { + const outboxData = { + originHostEpoch: 'epoch-1', + messageId: 'mail-1', + fromSessionId: 'source', + fromSessionName: 'Source', + toSessionId: 'target', + targetSessionName: 'Target', + kind: 'request' as const, + text: 'Recover me', + }; + const attempts: StoredMessage[] = [ + { type: 'system_note', id: 'attempt-1', ts: 1, kind: 'session_mailbox_outbox', data: outboxData }, + { type: 'system_note', id: 'attempt-2', ts: 2, kind: 'session_mailbox_outbox', data: { ...outboxData, originHostEpoch: 'epoch-2' } }, + ]; + const pending = materializeChat(attempts); + assert.equal(pending.length, 1); + assert.equal(pending[0]?.id, 'attempt-2'); + assert.equal(pending[0]?.sessionMailbox?.direction, 'outgoing'); + if (pending[0]?.sessionMailbox?.direction === 'outgoing') { + assert.equal(pending[0].sessionMailbox.disposition, 'pending'); + } + + const settled = materializeChat([...attempts, { + type: 'system_note', + id: 'receipt', + ts: 3, + kind: 'session_mailbox_sent', + data: { + messageId: 'mail-1', + targetSessionId: 'target', + targetSessionName: 'Target', + kind: 'request', + text: 'Recover me', + disposition: 'turn_started', + }, + }]); + assert.deepEqual(settled.map((item) => item.id), ['receipt']); + + const rejected = materializeChat([...attempts, { + type: 'system_note', + id: 'rejection', + ts: 3, + kind: 'session_mailbox_failed', + data: { + ...outboxData, + errorCode: 'session_busy', + errorMessage: 'Target Session is busy', + }, + }]); + assert.deepEqual(rejected, []); + }); }); // ── #1307: the timeline model stays flat (fold is a render concern) ────────── diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index e0e6f27383..50200e2acf 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -19,7 +19,7 @@ import { memo, useEffect, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'; import { useMountedRef } from './use-mounted-ref.js'; -import { ICON_SIZE, AlertOctagon, Ban, Check, Copy, GitBranch, Info, Pencil, RefreshCcw, Timer } from './icons.js'; +import { ICON_SIZE, AlertOctagon, ArrowDown, ArrowUp, Ban, Check, Copy, GitBranch, Info, Pencil, RefreshCcw, Timer } from './icons.js'; import { type ClipboardCopyPhase, useClipboardCopyFeedback } from './clipboard-feedback.js'; import { Markdown } from './markdown.js'; import { @@ -59,6 +59,7 @@ import { } from '@maka/core/events'; import { finalAssistantReplyText, + type ChatItem, type TurnTimelineItem, type TurnViewModel, } from './materialize.js'; @@ -172,6 +173,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { attachments?: readonly AttachmentRef[]; quotes?: readonly QuoteRef[]; inlineReferences?: readonly InlineReference[]; + sessionMailbox?: ChatItem['sessionMailbox']; onReadAttachmentBytes?: ReadAttachmentBytes; /** When set on a user message, show an edit affordance that starts a revision draft. */ onEditUserMessage?: () => void; @@ -185,6 +187,13 @@ const UserMessageBody = memo(function UserMessageBody(props: { const editActionLabel = props.editDisabled ? (props.editDisabledReason ?? copyText.editMessageDisabledRunning) : copyText.editMessage; + const renderedText = props.inlineReferences ? ( + + ) : ( + + {props.text} + + ); const userMetadata = ( ) : null} - {props.inlineReferences ? ( - - ) : ( - - {props.text} - - )} + {props.sessionMailbox ? ( +
+ {props.sessionMailbox.direction === 'incoming' + ?
+ ) : null} + {props.sessionMailbox ? ( +
{renderedText}
+ ) : renderedText}
); @@ -534,12 +563,14 @@ export const TurnView = memo(function TurnView(props: { {turn.user && ( )} - {turn.notes.map((note) => ( + {turn.notes.filter((note) => !note.sessionMailbox).map((note) => ( @@ -753,6 +786,21 @@ export const TurnView = memo(function TurnView(props: { ); })} + {turn.notes.filter((note) => note.sessionMailbox?.direction === 'outgoing').map((note) => ( + + + + ))} ); }); diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 49a09e979c..1e2bcf85eb 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -136,6 +136,8 @@ export interface ComposerSlashCommandOption { description?: string; keywords?: readonly string[]; Icon?: LucideIcon; + /** Run immediately when the command is chosen from the `/` menu. */ + onChoose?(): void; } type ComposerSlashSuggestion = @@ -360,6 +362,14 @@ export const Composer = forwardRef< cancelLabel: string; onCancel(): void; }; + /** Optional routing banner for a message addressed to another Session. */ + sendTargetNotice?: { + title: string; + detail?: string; + cancelLabel: string; + status?: 'ready' | 'sending' | 'failed'; + onCancel(): void; + }; /** * Where a NEW chat starts. Rendered at the end of the footer's send-context * group and only while no session owns the composer: the project is fixed @@ -1000,6 +1010,10 @@ export const Composer = forwardRef< onSelect: (item): string | ChatComposerToken => { const suggestion = item.auxiliaryData as ComposerSlashSuggestion; if (suggestion.kind === 'command') { + if (suggestion.command.onChoose) { + suggestion.command.onChoose(); + return ''; + } return `/${suggestion.command.id} `; } return inlineReferenceToken({ @@ -1226,6 +1240,12 @@ export const Composer = forwardRef< if (event.key === 'Escape' && dragActive) { setDragActive(false); } + if (event.key === 'Escape' && props.sendTargetNotice) { + event.preventDefault(); + if (props.sendTargetNotice.status === 'sending') return; + props.sendTargetNotice.onCancel(); + return; + } // Esc during streaming interrupts the model. We don't preventDefault // unconditionally so Esc still works to close modals when the composer // happens to be focused outside a streaming turn. @@ -1539,6 +1559,28 @@ export const Composer = forwardRef< /> )} + {!props.hidden && props.sendTargetNotice && ( +
+
+ )} {!props.hidden && queueCount > 0 ? ( string; + mailboxOutgoingAriaLabel: (sessionName: string) => string; userAriaLabel: string; systemAriaLabel: string; assistantAriaLabel: string; @@ -497,6 +504,7 @@ const CONVERSATION_COPY = { }, messages: { you: '你', assistant: 'Maka', processing: '正在处理…', continuing: '继续中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盘算…', '正在钻研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓捣…', '正在酝酿…', '正在攻坚…', '正在权衡…', '正在拾掇…'], providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, 'zh')}后重试(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重试(${attempt}/${maxAttempts})`, providerRetryReason: { network: '网络中断', provider_capacity: '模型服务暂时满载', provider_unavailable: '模型服务暂时不可用', rate_limit: '触发模型速率限制', timeout: '请求超时', unknown: '模型请求失败' }, safeResumePending: '正在验证…', safeResume: '安全恢复', thinking: '深度思考', truncated: '已截断', copied: '已复制', copying: '复制中', copyFailed: '复制失败', copy: '复制', editMessage: '编辑并重发', editMessageDisabledRunning: '当前回答仍在进行中,结束后再编辑', editMessageDisabledAttachments: '包含附件的历史消息暂不支持编辑并重发', editMessageDisabledQuotes: '包含引用的历史消息暂不支持编辑并重发', editMessageDisabledTransformedText: '通过显式技能发送的历史消息暂不支持编辑并重发', + mailboxIncoming: '来自任务', mailboxOutgoing: '发送到任务', mailboxDelivered: '已送达 · 已开始处理', mailboxQueued: '已排队 · 将在下一轮处理', mailboxPending: '送达待确认 · 正在恢复', mailboxIncomingAriaLabel: (sessionName) => `来自任务“${sessionName}”的消息`, mailboxOutgoingAriaLabel: (sessionName) => `发送到任务“${sessionName}”的消息`, userAriaLabel: '你发送的消息', systemAriaLabel: '系统消息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}消息${context ? `:${context}` : ''}`, sourceAriaLabel: '本轮回答的来源', derivativesAriaLabel: '本轮回答的衍生', scheduledTaskTriggered: '定时任务触发', scheduledTaskTitle: (id) => `由定时任务触发 · ${id}`, legacyAutomationTriggered: '旧版自动化(仅历史)', legacyAutomationTitle: (id) => `由旧版自动化触发 · ${id} · 仅保留历史,不会再次执行`, goalContinued: 'Goal 自动继续', goalTitle: (id) => `由 Goal 继续执行 · ${id}`, agentGraphTriggered: 'Agent Graph 自动继续', agentGraphTitle: (graphId) => `由 Agent Graph 调度器触发 · ${graphId}`, thinkingTruncatedTitle: '部分 reasoning 已截断;显示的是最近的内容', outputTruncatedTitle: '助手输出已超过单次回合上限,超出部分未渲染。如需完整内容请重新生成或查看持久化的任务日志。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', quoteExpandAriaLabel: '展开引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '(已中断)', abortedByStop: '(已中断 · 由停止按钮触发)', systemNotes: { @@ -645,6 +653,7 @@ const CONVERSATION_COPY = { }, messages: { you: 'You', assistant: 'Maka', processing: 'Working…', continuing: 'Continuing…', workingPhrases: ['Pondering…', 'Tinkering…', 'Untangling…', 'Digging in…', 'Mulling…', 'Chewing on it…', 'Wrangling…', 'Piecing it together…'], providerRetryScheduled: (seconds, attempt, maxAttempts) => `Retrying in ${formatRetryDelay(seconds, 'en')} (${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `Retrying (${attempt}/${maxAttempts})`, providerRetryReason: { network: 'Network interrupted', provider_capacity: 'The model service is temporarily at capacity', provider_unavailable: 'Model service temporarily unavailable', rate_limit: 'Model rate limit reached', timeout: 'Request timed out', unknown: 'Model request failed' }, safeResumePending: 'Checking…', safeResume: 'Safe recovery', thinking: 'Thinking', truncated: 'Truncated', copied: 'Copied', copying: 'Copying', copyFailed: 'Copy failed', copy: 'Copy', editMessage: 'Edit & resend', editMessageDisabledRunning: 'Wait for this answer to finish before editing', editMessageDisabledAttachments: 'Edit & resend does not yet support messages with attachments', editMessageDisabledQuotes: 'Edit & resend does not yet support messages with quotes', editMessageDisabledTransformedText: 'Edit & resend does not yet support messages sent with an explicit skill', + mailboxIncoming: 'From task', mailboxOutgoing: 'Sent to task', mailboxDelivered: 'Delivered · processing started', mailboxQueued: 'Queued · will process next turn', mailboxPending: 'Delivery pending · recovering', mailboxIncomingAriaLabel: (sessionName) => `Message from task “${sessionName}”`, mailboxOutgoingAriaLabel: (sessionName) => `Message sent to task “${sessionName}”`, userAriaLabel: 'Your message', systemAriaLabel: 'System message', assistantAriaLabel: "Maka's response", answerActionsAriaLabel: (context) => `Response actions${context ? `: ${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action} response${context ? `: ${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action} message${context ? `: ${context}` : ''}`, sourceAriaLabel: 'Source of this response', derivativesAriaLabel: 'Responses derived from this one', scheduledTaskTriggered: 'Triggered by scheduled task', scheduledTaskTitle: (id) => `Triggered by scheduled task · ${id}`, legacyAutomationTriggered: 'Legacy Automation (history only)', legacyAutomationTitle: (id) => `Triggered by legacy Automation · ${id} · Historical only; it will not run again`, goalContinued: 'Continued by Goal', goalTitle: (id) => `Continued by Goal · ${id}`, agentGraphTriggered: 'Continued by Agent Graph', agentGraphTitle: (graphId) => `Triggered by the Agent Graph scheduler · ${graphId}`, thinkingTruncatedTitle: 'Some reasoning was truncated; showing the most recent content', outputTruncatedTitle: 'The assistant output exceeded the per-turn limit. Regenerate it or inspect the persisted task log for the complete content.', removeAttachmentAriaLabel: (name) => `Remove ${name}`, quoteLabel: 'Quote', quoteExpandAriaLabel: 'Show the full quoted excerpt', quoteCollapseAriaLabel: 'Collapse the quoted excerpt', removeQuoteAriaLabel: 'Remove quote', aborted: '(Interrupted)', abortedByStop: '(Interrupted · Stop button)', systemNotes: { diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index 2f26a26174..6cb4ea00a2 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -39,6 +39,13 @@ import type { import type { ToolActivityStatus } from '@maka/core/tool-result-status'; import type { ShellRunToolResult } from '@maka/core/shell-run-result'; import type { StoredMessage, TurnRecord, TurnStatus, UserMessage } from '@maka/core/session'; +import { + parseSessionMailboxFailedNoteData, + parseSessionMailboxOutboxNoteData, + parseSessionMailboxSentNoteData, + parseTrustedSessionMailboxMessage, + type SessionMailboxKind, +} from '@maka/core/session-mailbox'; import type { UiLocale } from '@maka/core/ui-locale'; import type { LiveSteeringProjection, @@ -62,6 +69,21 @@ export interface ChatItem { inlineReferences?: InlineReference[]; /** Present when the Host authored this message instead of the user. */ hostOrigin?: NonNullable; + /** Structured cross-task message chrome; `text` remains body-only. */ + sessionMailbox?: + | { + direction: 'incoming'; + sessionId: string; + sessionName: string; + kind: SessionMailboxKind; + } + | { + direction: 'outgoing'; + sessionId: string; + sessionName: string; + kind: SessionMailboxKind; + disposition: 'pending' | 'turn_started' | 'queued'; + }; } /** @@ -155,24 +177,10 @@ export function materializeChat( locale: UiLocale = "en", ): ChatItem[] { const items: ChatItem[] = []; + const visibleOutboxIds = visibleSessionMailboxOutboxNoteIds(messages); for (const message of messages) { if (message.type === "user") { - items.push({ - id: message.id, - role: "user", - text: message.displayText ?? message.text, - ts: message.ts, - ...(message.attachments && message.attachments.length > 0 - ? { attachments: message.attachments } - : {}), - ...(message.quotes && message.quotes.length > 0 - ? { quotes: message.quotes } - : {}), - ...(message.inlineReferences !== undefined - ? { inlineReferences: message.inlineReferences } - : {}), - ...(message.origin ? { hostOrigin: message.origin } : {}), - }); + items.push(chatItemFromUserMessage(message)); } if (message.type === "assistant") items.push({ @@ -181,7 +189,17 @@ export function materializeChat( text: message.text, ts: message.ts, }); - if ( + if (message.type === 'system_note' && message.kind === 'session_mailbox_sent') { + const item = chatItemFromMailboxSentNote(message); + if (item) items.push(item); + } else if ( + message.type === 'system_note' && + message.kind === 'session_mailbox_outbox' && + visibleOutboxIds.has(message.id) + ) { + const item = chatItemFromMailboxOutboxNote(message); + if (item) items.push(item); + } else if ( message.type === "system_note" && VISIBLE_SYSTEM_NOTES.has(message.kind) ) { @@ -657,6 +675,7 @@ export function materializeTurns( locale: UiLocale = "en", ): TurnViewModel[] { const turnRecords = deriveTurnRecords(messages); + const visibleOutboxIds = visibleSessionMailboxOutboxNoteIds(messages); const turnRecordById = new Map( turnRecords.map((turn) => [turn.turnId, turn]), ); @@ -755,6 +774,16 @@ export function materializeTurns( if (message.ts !== undefined && message.ts >= turn.startedAt) { turn.durationMs = message.ts - turn.startedAt; } + } else if (message.type === 'system_note' && message.kind === 'session_mailbox_sent') { + const note = chatItemFromMailboxSentNote(message); + if (note) turn.notes.push(note); + } else if ( + message.type === 'system_note' && + message.kind === 'session_mailbox_outbox' && + visibleOutboxIds.has(message.id) + ) { + const note = chatItemFromMailboxOutboxNote(message); + if (note) turn.notes.push(note); } else if ( message.type === "system_note" && VISIBLE_SYSTEM_NOTES.has(message.kind) @@ -1073,10 +1102,14 @@ function chatItemFromContent( content: MessageContent, hostOrigin?: NonNullable, ): ChatItem { + const mailbox = parseTrustedSessionMailboxMessage({ + text: content.text, + origin: hostOrigin, + }); return { id, role: "user", - text: content.displayText ?? content.text, + text: mailbox?.text ?? content.displayText ?? content.text, ts, ...(content.attachments && content.attachments.length > 0 ? { attachments: content.attachments } @@ -1088,6 +1121,94 @@ function chatItemFromContent( ? { inlineReferences: content.inlineReferences } : {}), ...(hostOrigin ? { hostOrigin } : {}), + ...(mailbox + ? { + sessionMailbox: { + direction: 'incoming' as const, + sessionId: mailbox.fromSessionId, + sessionName: mailbox.fromSessionName, + kind: mailbox.kind, + }, + } + : {}), + }; +} + +function chatItemFromMailboxOutboxNote( + message: Extract, +): ChatItem | undefined { + const outbox = parseSessionMailboxOutboxNoteData(message.data); + if (!outbox) return undefined; + return { + id: message.id, + role: 'system', + text: outbox.text, + ts: message.ts, + sessionMailbox: { + direction: 'outgoing', + sessionId: outbox.toSessionId, + sessionName: outbox.targetSessionName, + kind: outbox.kind, + disposition: 'pending', + }, + }; +} + +/** Show only the latest unsettled delivery attempt for each mailbox message. */ +function visibleSessionMailboxOutboxNoteIds( + messages: readonly StoredMessage[], +): ReadonlySet { + const terminalMessageIds = new Set(); + const seenOutboxMessageIds = new Set(); + const visibleNoteIds = new Set(); + // Receipts follow their outboxes durably. Walking backward therefore finds + // a terminal receipt before any of its attempts and the newest unsettled + // attempt before older retries, without adding two full transcript passes. + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.type !== 'system_note') continue; + if (message.kind === 'session_mailbox_sent') { + const receipt = parseSessionMailboxSentNoteData(message.data); + if (receipt) terminalMessageIds.add(receipt.messageId); + continue; + } + if (message.kind === 'session_mailbox_failed') { + const rejection = parseSessionMailboxFailedNoteData(message.data); + if (rejection) terminalMessageIds.add(rejection.messageId); + continue; + } + if (message.kind !== 'session_mailbox_outbox') continue; + const outbox = parseSessionMailboxOutboxNoteData(message.data); + if ( + !outbox || + terminalMessageIds.has(outbox.messageId) || + seenOutboxMessageIds.has(outbox.messageId) + ) { + continue; + } + seenOutboxMessageIds.add(outbox.messageId); + visibleNoteIds.add(message.id); + } + return visibleNoteIds; +} + +function chatItemFromMailboxSentNote( + message: Extract, +): ChatItem | undefined { + const receipt = parseSessionMailboxSentNoteData(message.data); + if (!receipt) return undefined; + return { + id: message.id, + role: 'system', + text: receipt.text, + ts: message.ts, + sessionMailbox: { + direction: 'outgoing', + sessionId: receipt.targetSessionId, + sessionName: receipt.targetSessionName, + kind: receipt.kind, + disposition: receipt.disposition, + }, }; }