From b28396e913efe2daa2c777f3357f6854015d0d94 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:34:54 -0700 Subject: [PATCH] fix: resolve structured chat overnight anomalies --- ...runtime-structured-session-restore.test.ts | 101 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 38 ++++++- .../native-chat/NativeChatComposer.test.tsx | 45 +++++++- .../native-chat/NativeChatComposer.tsx | 33 ++++-- .../native-chat/NativeChatComposerActions.tsx | 6 +- .../native-chat/NativeChatComposerField.tsx | 6 +- .../NativeChatSessionOptionPickers.test.tsx | 47 +++++++- .../NativeChatSessionOptionPickers.tsx | 19 +++- .../NativeChatStructuredSession.test.tsx | 59 +++++++++- .../NativeChatStructuredSession.tsx | 22 +++- .../native-chat-attachment-upload.test.ts | 23 ++++ .../native-chat-attachment-upload.ts | 19 +++- .../native-chat/native-chat-composer-types.ts | 10 +- ...ative-chat-structured-composer-dispatch.ts | 6 +- ...d-agent-session-message-projection.test.ts | 72 +++++++++++++ ...ctured-agent-session-message-projection.ts | 30 ++++++ ...-native-chat-composer-attachments.test.tsx | 23 +++- .../use-native-chat-composer-attachments.ts | 16 ++- .../use-native-chat-composer-paste.test.tsx | 20 ++++ .../use-native-chat-composer-paste.ts | 5 + ...-native-chat-external-attachments.test.tsx | 16 +++ .../use-native-chat-external-attachments.ts | 17 ++- ...e-structured-agent-session-outbox.test.tsx | 27 +++++ .../use-structured-agent-session-outbox.ts | 6 +- .../use-structured-agent-session.ts | 21 ++-- .../structured-agent-session-reducer.test.ts | 96 +++++++++++++++++ .../structured-agent-session-reducer.ts | 11 +- 27 files changed, 735 insertions(+), 59 deletions(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 13d23426cc9..a5b2d7b6887 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -52,4 +52,105 @@ describe('structured session cold restoration', () => { hydrate.mock.invocationCallOrder[0] ?? Infinity ) }) + + it('normalizes a restored tab id and removes it when closed', async () => { + const runtime = new OrcaRuntimeService() + const internal = runtime as unknown as { + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() + internal.refreshMobileSessionPtyRecords = async () => new Set() + internal.ensureStructuredAgentSessionHost = async () => undefined + setStructuredAgentSessionHost({ + restoreReadableSessions: async () => undefined, + listSessionTabs: () => [ + { + sessionId: 'agent-session:agent-session:restored-session', + workspaceId: 'workspace-1', + agent: 'codex' + } + ] + } as never) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: 'workspace-1', + publicationEpoch: 'renderer-restored', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'terminal-tab::leaf-1', + activeTabType: 'terminal', + tabGroups: [{ id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] }], + tabs: [ + { + type: 'terminal', + id: 'terminal-tab::leaf-1', + parentTabId: 'terminal-tab', + leafId: 'leaf-1', + title: 'Terminal', + isActive: true + }, + { + type: 'terminal', + id: 'terminal-tab::leaf-2', + parentTabId: 'terminal-tab', + leafId: 'leaf-2', + title: 'Terminal', + isActive: false + } + ] + } + ] + }) + + await runtime.restoreStructuredAgentSessionTabs() + + const restored = await runtime.listMobileSessionTabs('id:workspace-1') + expect(restored.tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'terminal', + id: 'terminal-tab::leaf-1' + }), + expect.objectContaining({ + type: 'terminal', + id: 'terminal-tab::leaf-2' + }), + expect.objectContaining({ + type: 'agent-session', + id: 'agent-session:restored-session', + sessionId: 'restored-session' + }) + ]) + ) + expect(restored.tabs).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'agent-session', + id: 'agent-session:agent-session:restored-session' + }) + ]) + ) + expect(restored.tabGroups?.[0]?.tabOrder).toEqual([ + 'terminal-tab', + 'agent-session:restored-session' + ]) + + await runtime.closeMobileSessionTab('id:workspace-1', 'agent-session:restored-session', { + reason: 'user' + }) + + const closed = await runtime.listMobileSessionTabs('id:workspace-1') + expect(closed.tabs.map((tab) => tab.id)).toEqual([ + 'terminal-tab::leaf-1', + 'terminal-tab::leaf-2' + ]) + expect(closed.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab']) + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1e1523e7bf6..c2b95928b69 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -8163,6 +8163,9 @@ export class OrcaRuntimeService { // route closeSessionTab to. Close the page directly and drop it from the // snapshot so paired clients stop showing it. await this.closeHeadlessMobileBrowserTab(worktreeId, snapshot!, tab) + } else if (tab.type === 'agent-session') { + this.notifier?.closeSessionTab?.(tab.id, worktreeId) + this.closeStructuredAgentSessionTab(worktreeId, snapshot!, tab) } else { this.notifier?.closeSessionTab?.(tab.id, worktreeId) } @@ -8234,6 +8237,30 @@ export class OrcaRuntimeService { this.emitMobileSessionTabsSnapshot(nextSnapshot) } + private closeStructuredAgentSessionTab( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionAgentTab + ): void { + const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id) + const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + snapshotVersion: snapshot.snapshotVersion + 1, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + tabGroups: (snapshot.tabGroups ?? []).map((group) => ({ + ...group, + tabOrder: group.tabOrder.filter((id) => id !== tab.id), + activeTabId: group.activeTabId === tab.id ? null : group.activeTabId, + recentTabIds: group.recentTabIds?.filter((id) => id !== tab.id) + })), + tabs: nextTabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + } + private markHeadlessBrowserSessionTabActive( worktreeId: string | undefined, browserPageId: string, @@ -9999,7 +10026,16 @@ export class OrcaRuntimeService { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() const host = getStructuredAgentSessionHost() for (const session of host?.listSessionTabs() ?? []) { - this.publishStructuredAgentSessionTab({ ...session, activate: false, notify: false }) + let sessionId = session.sessionId + while (sessionId.startsWith('agent-session:')) { + sessionId = sessionId.slice('agent-session:'.length) + } + this.publishStructuredAgentSessionTab({ + ...session, + sessionId, + activate: false, + notify: false + }) } } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 5e7f8b8c73c..2a75ab71766 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => ({ createClaudeModelSwitchConfirmationObserver: vi.fn(), discoverCommitMessageModels: vi.fn(), draft: 'hello', + imageAttachments: [] as { id: string; path: string }[], getMainBufferSnapshot: vi.fn(), sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, sendNativeChatMessage: vi.fn(), @@ -108,7 +109,7 @@ vi.mock('./use-native-chat-skills', () => ({ })) vi.mock('./use-native-chat-composer-attachments', () => ({ useNativeChatComposerAttachments: () => ({ - imageAttachments: [], + imageAttachments: mocks.imageAttachments, attachResolvedPaths: vi.fn(), clearImageAttachments: vi.fn(), removeImageAttachment: vi.fn() @@ -149,6 +150,7 @@ describe('NativeChatComposer', () => { mocks.fieldProps = null mocks.modelSwitchOutcome = 'applied' mocks.draft = 'hello' + mocks.imageAttachments = [] mocks.draftScopeKeys.length = 0 mocks.confirmationObserver = null mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => { @@ -276,15 +278,52 @@ describe('NativeChatComposer', () => { expect(mocks.fieldProps?.sessionOptionsSurface).toBe(optionsSurface) expect(mocks.fieldProps?.sessionOptionsSnapshot).toBe(optionSnapshot) - expect(mocks.fieldProps?.attachDisabled).toBe(true) + expect(mocks.fieldProps?.attachDisabled).toBe(false) await act(async () => mocks.fieldProps?.onSend?.()) expect(dispatchCommand).toHaveBeenCalledWith('hello') - expect(send).toHaveBeenCalledWith('hello') + expect(send).toHaveBeenCalledWith('hello', []) expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() expect(mocks.setDraft).toHaveBeenCalledWith('') }) + it('sends structured image attachments through the durable transport', async () => { + mocks.draft = '' + mocks.imageAttachments = [{ id: 'image-1', path: '/tmp/image.png' }] + const send = vi.fn(() => true) + render( + ({ + handled: false, + accepted: false, + error: null + })), + optionsSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, + optionSnapshot: [], + worktreeId: 'wt-1', + onError: vi.fn(), + runtime: 'local' + }} + /> + ) + + await act(async () => mocks.fieldProps?.onSend?.()) + + expect(send).toHaveBeenCalledWith('', mocks.imageAttachments) + expect(mocks.setDraft).toHaveBeenCalledWith('') + }) + it('types Codex slash composer sends instead of pasting them', () => { mocks.draft = '/status' render( diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index a097ff6743c..256e7878f7a 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -3,7 +3,10 @@ import { useAppStore } from '../../store' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' -import { STRUCTURED_AGENT_SESSION_SLASH_COMMANDS } from '../../../../shared/structured-agent-session-composer' +import { + isStructuredAgentSessionComposerCommand, + STRUCTURED_AGENT_SESSION_SLASH_COMMANDS +} from '../../../../shared/structured-agent-session-composer' import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' import { applyMentionSuggestion, @@ -169,6 +172,7 @@ export const NativeChatComposer = forwardRef { + (text: string, attachments = imageAttachments): void => { if (!structuredTransport) { return } - void dispatchNativeChatStructuredComposerText(structuredTransport, text) + if (attachments.length > 0 && isStructuredAgentSessionComposerCommand(text, agent)) { + structuredTransport.onError('Remove attachments before using a chat-session command.') + return + } + void dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) .then(({ accepted, error }) => { structuredTransport.onError(error) if (!accepted) { @@ -260,7 +269,14 @@ export const NativeChatComposer = forwardRef { if (!structuredTransport) { sendPty() - } else if (draft.trim() !== '' && !disabled) { - sendStructured(draft) + } else if ((draft.trim() !== '' || imageAttachments.length > 0) && !disabled) { + sendStructured(draft, imageAttachments) } - }, [disabled, draft, sendPty, sendStructured, structuredTransport]) + }, [disabled, draft, imageAttachments, sendPty, sendStructured, structuredTransport]) const interrupt = useCallback(() => { cancelPendingSends() @@ -375,7 +391,7 @@ export const NativeChatComposer = forwardRef ) } diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index aee0f79c3c0..4891c94417c 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -7,6 +7,7 @@ import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' +import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' export type NativeChatComposerActionsProps = { attachDisabled: boolean @@ -23,6 +24,7 @@ export type NativeChatComposerActionsProps = { onStop?: () => void sessionOptionsSurface: SessionOptionsSurface | null sessionOptionsSnapshot: SessionOptionDescriptor[] + sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null } export function NativeChatComposerActions({ @@ -39,7 +41,8 @@ export function NativeChatComposerActions({ onSend, onStop, sessionOptionsSurface, - sessionOptionsSnapshot + sessionOptionsSnapshot, + sessionOptionsPickerRequest }: NativeChatComposerActionsProps): React.JSX.Element { const dictationLabel = isDictating ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') @@ -73,6 +76,7 @@ export function NativeChatComposerActions({ surface={sessionOptionsSurface} snapshot={sessionOptionsSnapshot} isWorking={isWorking} + pickerRequest={sessionOptionsPickerRequest} /> diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index 6f954a60aa5..7206b0288bd 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -18,6 +18,7 @@ import type { SessionOptionDescriptor, SessionOptionsSurface } from '../../../../shared/native-chat-session-options' +import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' export type NativeChatComposerFieldProps = { textareaRef: RefObject @@ -54,6 +55,7 @@ export type NativeChatComposerFieldProps = { onStop?: () => void sessionOptionsSurface: SessionOptionsSurface | null sessionOptionsSnapshot: SessionOptionDescriptor[] + sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null } export type NativeChatComposerImageAttachment = { @@ -95,7 +97,8 @@ export function NativeChatComposerField({ onSend, onStop, sessionOptionsSurface, - sessionOptionsSnapshot + sessionOptionsSnapshot, + sessionOptionsPickerRequest }: NativeChatComposerFieldProps): React.JSX.Element { return (
@@ -213,6 +216,7 @@ export function NativeChatComposerField({ onStop={onStop} sessionOptionsSurface={sessionOptionsSurface} sessionOptionsSnapshot={sessionOptionsSnapshot} + sessionOptionsPickerRequest={sessionOptionsPickerRequest} />
diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index cc9963b94b2..d53e8f536d2 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -32,7 +32,17 @@ vi.mock('@/components/ui/tooltip', () => ({ vi.mock('@/components/ui/dropdown-menu', () => { const React = require('react') as typeof ReactModule return { - DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenu: ({ + children, + defaultOpen + }: { + children: React.ReactNode + defaultOpen?: boolean + }) => ( +
+ {children} +
+ ), DropdownMenuTrigger: ({ children, disabled @@ -188,6 +198,41 @@ const fast: SessionOptionDescriptor = { afterEach(() => cleanup()) describe('NativeChatSessionOptionPickers', () => { + it('opens the native picker requested by a structured slash command', async () => { + const { rerender } = render( + + ) + + rerender( + + ) + await waitFor(() => + expect(screen.getAllByTestId('dropdown-root')[1]?.getAttribute('data-open')).toBe('true') + ) + + rerender( + + ) + await waitFor(() => + expect(screen.getAllByTestId('dropdown-root')[0]?.getAttribute('data-open')).toBe('true') + ) + }) + it('prefers collision-aware upward placement for model and option menus', () => { render( (null) const model = snapshot.find((descriptor) => descriptor.category === 'model') @@ -215,6 +218,10 @@ function NativeChatSessionOptionPickersInner({ if (!surface || !model) { return null } + const requestedModelSequence = pickerRequest?.id === model.id ? pickerRequest.sequence : null + const requestedOptionsSequence = options.some((descriptor) => descriptor.id === pickerRequest?.id) + ? (pickerRequest?.sequence ?? null) + : null const setOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { runSurfaceCall(descriptor.id, setPendingId, () => surface.setOption(descriptor.id, value)) @@ -234,7 +241,10 @@ function NativeChatSessionOptionPickersInner({ return (
{options.length > 0 ? ( - + ) : null} - + ({ @@ -8,7 +8,8 @@ const mocks = vi.hoisted(() => ({ messageListProps: null as null | { allowFileUriLinks?: boolean onLinkClick?: (...args: unknown[]) => void - } + }, + composerProps: null as null | { structuredTransport?: Record } })) vi.mock('./use-structured-agent-session', () => ({ @@ -36,8 +37,26 @@ vi.mock('./use-structured-agent-session', () => ({ turnId: null, cancel: vi.fn(), respond: vi.fn(), - optionSnapshot: [], - optionSurface: null, + optionSnapshot: [ + { + id: 'model', + label: 'Model', + category: 'model', + kind: { + type: 'select', + currentValue: 'gpt-live', + choices: [{ value: 'gpt-live', label: 'GPT Live' }] + }, + valueSource: 'reported', + settable: true + } + ], + optionSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, setStructuredOption: vi.fn() }) })) @@ -65,7 +84,12 @@ vi.mock('./NativeChatMessageList', () => ({ } })) -vi.mock('./NativeChatComposer', () => ({ NativeChatComposer: () => null })) +vi.mock('./NativeChatComposer', () => ({ + NativeChatComposer: (props: typeof mocks.composerProps) => { + mocks.composerProps = props + return null + } +})) vi.mock('./NativeChatEmptyState', () => ({ NativeChatEmptyState: () => null })) vi.mock('./NativeChatApprovalCard', () => ({ NativeChatApprovalCard: () => null })) vi.mock('./NativeChatQuestionCard', () => ({ NativeChatQuestionCard: () => null })) @@ -76,6 +100,7 @@ describe('NativeChatStructuredSession', () => { afterEach(() => { cleanup() mocks.messageListProps = null + mocks.composerProps = null }) it('wires local structured file links through the native chat opener', () => { @@ -92,4 +117,28 @@ describe('NativeChatStructuredSession', () => { expect(mocks.messageListProps?.allowFileUriLinks).toBe(true) expect(mocks.messageListProps?.onLinkClick).toBe(mocks.fileLinkClick) }) + + it('routes a bare model command to the native option picker', async () => { + render( + + ) + const dispatchCommand = mocks.composerProps?.structuredTransport?.dispatchCommand as + | ((text: string) => Promise<{ accepted: boolean }>) + | undefined + + await act(async () => { + await expect(dispatchCommand?.('/model')).resolves.toMatchObject({ accepted: true }) + }) + + expect(mocks.composerProps?.structuredTransport?.optionPickerRequest).toEqual({ + id: 'model', + sequence: 1 + }) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 540db7afef8..cd22005b524 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -31,6 +31,10 @@ export function NativeChatStructuredSession(props: { }): React.JSX.Element { const controller = useStructuredAgentSession(props) const [composerError, setComposerError] = useState(null) + const [optionPickerRequest, setOptionPickerRequest] = useState<{ + id: string + sequence: number + } | null>(null) const paneKey = useMemo( () => structuredAgentSessionPaneKey(props.tabId, props.sessionId), [props.sessionId, props.tabId] @@ -77,20 +81,32 @@ export function NativeChatStructuredSession(props: { null const structuredTransport = useMemo( () => ({ - send: controller.send, + send: (text: string, attachments: readonly { id: string; path: string }[]): boolean => + controller.send( + text, + attachments.map((attachment) => ({ + path: attachment.path, + previewUri: attachment.path + })) + ), dispatchCommand: (text: string) => dispatchStructuredAgentSessionComposerCommand(text, { agent: props.agent, snapshot: controller.optionSnapshot, - invokeAction: async () => false, + invokeAction: async (id) => { + setOptionPickerRequest((current) => ({ id, sequence: (current?.sequence ?? 0) + 1 })) + return true + }, setOption: controller.setStructuredOption }), optionsSurface: controller.optionSurface, optionSnapshot: controller.optionSnapshot, + optionPickerRequest, + worktreeId: fileLinkContext?.worktreeId, onError: setComposerError, runtime: (props.target.kind === 'local' ? 'local' : 'remote') as 'local' | 'remote' }), - [controller, props.agent, props.target.kind] + [controller, fileLinkContext?.worktreeId, optionPickerRequest, props.agent, props.target.kind] ) return ( diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts index 3dde6373391..e90b7edcb44 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts @@ -25,6 +25,7 @@ vi.mock('@/i18n/i18n', () => ({ import { resolveNativeChatAttachmentOwner, + resolveNativeChatAttachmentOwnerForWorktree, uploadNativeChatAttachmentPaths } from './native-chat-attachment-upload' @@ -66,6 +67,28 @@ describe('resolveNativeChatAttachmentOwner', () => { expect(resolveNativeChatAttachmentOwner(state(), 'tab-1')).toEqual({ kind: 'local' }) }) + it('resolves a structured tab owner directly from its worktree', () => { + expect(resolveNativeChatAttachmentOwnerForWorktree(state(), 'wt-1')).toEqual({ + kind: 'local' + }) + }) + + it('resolves a structured SSH owner directly from its worktree', () => { + expect( + resolveNativeChatAttachmentOwnerForWorktree( + state({ + repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, + sshConnectionStates: new Map([['conn-1', { connectionGeneration: 4 } as never]]) + }), + 'wt-1' + ) + ).toMatchObject({ + kind: 'ssh', + connectionId: 'conn-1', + worktreePath: '/repo/worktree' + }) + }) + it('resolves an SSH repo worktree to ssh with the worktree path', () => { expect( resolveNativeChatAttachmentOwner( diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 76b3b6471c8..8157a5190ff 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -58,6 +58,14 @@ export function resolveNativeChatAttachmentOwner( if (!worktreeId) { return { kind: 'not-ready' } } + return resolveNativeChatAttachmentOwnerForWorktree(state, worktreeId, terminalTabId) +} + +export function resolveNativeChatAttachmentOwnerForWorktree( + state: NativeChatAttachmentOwnerState, + worktreeId: string, + terminalTabId?: string +): NativeChatAttachmentOwner { if (getRuntimeEnvironmentIdForWorktree(state, worktreeId)) { return { kind: 'runtime' } } @@ -68,7 +76,9 @@ export function resolveNativeChatAttachmentOwner( if (connectionId === null) { return { kind: 'local' } } - const worktreePath = resolveNativeChatFileLinkContext(state, terminalTabId)?.worktreePath + const worktreePath = terminalTabId + ? resolveNativeChatFileLinkContext(state, terminalTabId)?.worktreePath + : state.getKnownWorktreeById(worktreeId)?.path if (!worktreePath) { return { kind: 'not-ready' } } @@ -87,6 +97,13 @@ export function nativeChatWorktreeNotReadyNotice(): string { ) } +export function nativeChatLocalAttachmentUnsupportedNotice(): string { + return translate( + 'components.native-chat.composer.localAttachmentUnsupported', + 'Local attachments are not available for remote sessions.' + ) +} + /** * Upload client-local paths into `${worktreePath}/.orca/drops` on the SSH * remote and return the remote paths the agent can read (input order diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 52d11cd64be..45ebde1b375 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -5,12 +5,20 @@ import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' + +export type NativeChatOptionPickerRequest = { + id: string + sequence: number +} export type NativeChatStructuredComposerTransport = { - send: (text: string) => boolean + send: (text: string, attachments: readonly NativeChatComposerImageAttachment[]) => boolean dispatchCommand: (text: string) => Promise optionsSurface: SessionOptionsSurface optionSnapshot: SessionOptionDescriptor[] + optionPickerRequest?: NativeChatOptionPickerRequest | null + worktreeId?: string onError: (message: string | null) => void runtime: 'local' | 'remote' } diff --git a/src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts b/src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts index 9660863a42a..a620bf71038 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts @@ -1,12 +1,14 @@ import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' export async function dispatchNativeChatStructuredComposerText( transport: NativeChatStructuredComposerTransport, - text: string + text: string, + attachments: readonly NativeChatComposerImageAttachment[] = [] ): Promise<{ accepted: boolean; error: string | null }> { const command = await transport.dispatchCommand(text) if (command.handled) { return { accepted: command.accepted, error: command.error } } - return { accepted: transport.send(text), error: null } + return { accepted: transport.send(text, attachments), error: null } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts new file mode 100644 index 00000000000..84e820d6300 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const SEND_COUNT = 8 + +function submission(index: number): AgentJournalSubmission { + return { + clientMessageId: `client-${index}`, + fence: 1, + payloadFingerprint: `fingerprint-${index}`, + dispatchState: 'accepted', + providerItemId: `provider-${index}`, + reason: null, + submittedAt: index, + resolvedAt: index + } +} + +function item(index: number): AgentJournalRenderItem { + return { + itemId: `journal-${index}`, + revision: 1, + sequence: index, + observedAt: index, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `send ${index}` }] } + } +} + +describe('structured agent session message projection', () => { + it('renders N rapid accepted sends once when journal and optimistic updates interleave', () => { + const outbox = Array.from({ length: SEND_COUNT }, (_, index) => + createStructuredAgentSessionOutboxEntry({ + clientMessageId: `client-${index}`, + sessionId: 'session-1', + text: `send ${index}`, + attachments: [], + queuedAt: index + }) + ) + const messages = projectStructuredAgentSessionMessages( + Array.from({ length: SEND_COUNT }, (_, index) => item(index)), + outbox, + Array.from({ length: SEND_COUNT }, (_, index) => submission(SEND_COUNT - index - 1)) + ) + + expect(messages).toHaveLength(SEND_COUNT) + expect(messages.map((message) => message.id)).toEqual( + Array.from({ length: SEND_COUNT }, (_, index) => `journal-${index}`) + ) + }) + + it('keeps an optimistic send until its acceptance arrives', () => { + const outbox = [ + createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'client-pending', + sessionId: 'session-1', + text: 'pending', + attachments: [], + queuedAt: 1 + }) + ] + + expect(projectStructuredAgentSessionMessages([], outbox, [])).toMatchObject([ + { id: 'client-pending', role: 'user' } + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts new file mode 100644 index 00000000000..434885a715c --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts @@ -0,0 +1,30 @@ +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + reconcileStructuredAgentSessionOutbox, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' + +export function projectStructuredAgentSessionMessages( + items: readonly AgentJournalRenderItem[], + outbox: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] +): NativeChatMessage[] { + const optimistic = reconcileStructuredAgentSessionOutbox(outbox, submissions) + return [ + ...projectStructuredItemsToNativeChat(items), + ...optimistic.map( + (entry): NativeChatMessage => ({ + id: entry.clientMessageId, + role: 'user', + source: 'transcript', + timestamp: entry.queuedAt, + blocks: entry.body.blocks + }) + ) + ] +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index 82d788e94f6..06b61d9610b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -25,9 +25,11 @@ const target: NativeChatResolvedTarget = { function Probe({ scopeKey, + structured = false, onReady }: { scopeKey: string + structured?: boolean onReady: (api: ProbeApi) => void }): React.JSX.Element { const [caret, setCaret] = useState(0) @@ -36,8 +38,9 @@ function Probe({ const textareaRef = useRef(null) const api = useNativeChatComposerAttachments({ attachmentScopeKey: scopeKey, + allowWithoutTarget: structured, caret, - resolveTarget: () => target, + resolveTarget: () => (structured ? null : target), textareaRef, setCaret, setDraft: (updater) => setDraftValue((previous) => updater(previous)), @@ -48,7 +51,8 @@ function Probe({ } async function renderProbe( - scopeKey: string + scopeKey: string, + structured = false ): Promise<{ root: Root; latest: () => ProbeApi; rerender: (scopeKey: string) => Promise }> { const container = document.createElement('div') document.body.append(container) @@ -60,7 +64,7 @@ async function renderProbe( api = next } await act(async () => { - root.render(createElement(Probe, { scopeKey, onReady })) + root.render(createElement(Probe, { scopeKey, structured, onReady })) }) if (!api) { throw new Error('Probe did not render') @@ -75,7 +79,7 @@ async function renderProbe( }, rerender: async (nextScopeKey: string) => { await act(async () => { - root.render(createElement(Probe, { scopeKey: nextScopeKey, onReady })) + root.render(createElement(Probe, { scopeKey: nextScopeKey, structured, onReady })) }) } } @@ -112,6 +116,17 @@ describe('useNativeChatComposerAttachments', () => { act(() => second.root.unmount()) }) + it('accepts host-readable image paths without a PTY for structured transport', async () => { + const probe = await renderProbe('structured-session-1', true) + + await act(async () => { + probe.latest().attachResolvedPaths(['/tmp/structured-image.png']) + }) + + expect(probe.latest().imageAttachments).toMatchObject([{ path: '/tmp/structured-image.png' }]) + act(() => probe.root.unmount()) + }) + it('removes an attached image chip cleanly', async () => { const probe = await renderProbe('pty-1') await act(async () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 60ecf44ff5a..b9d94d8e253 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -11,6 +11,7 @@ import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string + allowWithoutTarget?: boolean caret: number resolveTarget: () => NativeChatResolvedTarget | null textareaRef: RefObject @@ -21,6 +22,7 @@ export type UseNativeChatComposerAttachmentsArgs = { export function useNativeChatComposerAttachments({ attachmentScopeKey, + allowWithoutTarget = false, caret, resolveTarget, textareaRef, @@ -107,7 +109,10 @@ export function useNativeChatComposerAttachments({ const attachResolvedPaths = useCallback( (paths: string[]) => { const target = resolveTarget() - if (!target || nativeChatComposerTargetIsRemote(target.ptyId)) { + if ( + (!target && !allowWithoutTarget) || + (target && nativeChatComposerTargetIsRemote(target.ptyId)) + ) { setNotice( translate( 'components.native-chat.composer.localAttachmentUnsupported', @@ -128,7 +133,14 @@ export function useNativeChatComposerAttachments({ requestAnimationFrame(() => textareaRef.current?.focus()) } }, - [appendImageAttachments, insertFileReferences, resolveTarget, setNotice, textareaRef] + [ + allowWithoutTarget, + appendImageAttachments, + insertFileReferences, + resolveTarget, + setNotice, + textareaRef + ] ) return { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx index 158ab0bfa13..7f5d0e2f1b9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx @@ -18,6 +18,8 @@ vi.mock('./native-chat-composer-target', () => ({ })) vi.mock('./native-chat-attachment-upload', () => ({ + nativeChatLocalAttachmentUnsupportedNotice: () => + 'Local attachments are not available for remote sessions.', nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.' })) @@ -133,6 +135,24 @@ afterEach(() => { }) describe('useNativeChatComposerPaste', () => { + it('does not save a clipboard image locally for a remote runtime', async () => { + const setNotice = vi.fn() + const attachResolvedPaths = vi.fn() + const probe = await renderProbe({ + resolveAttachmentOwner: () => ({ kind: 'runtime' }), + attachResolvedPaths, + setNotice + }) + + await act(async () => probe.latest().pasteFromClipboard()) + + expect(setNotice).toHaveBeenCalledWith( + 'Local attachments are not available for remote sessions.' + ) + expect(mocks.saveClipboardImageAsTempFile).not.toHaveBeenCalled() + expect(attachResolvedPaths).not.toHaveBeenCalled() + }) + it('surfaces a failed SSH image save through the composer notice', async () => { mocks.saveClipboardImageAsTempFile.mockRejectedValue( new Error('Remote connection dropped. Click Reconnect on the SSH target before retrying.') diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index dbb6a06ed03..c92c071209e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -5,6 +5,7 @@ import type { AgentType } from '../../../../shared/agent-status-types' import { resolveImagePaste } from './native-chat-image-paste' import { NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES } from './native-chat-composer-target' import { + nativeChatLocalAttachmentUnsupportedNotice, nativeChatWorktreeNotReadyNotice, type NativeChatAttachmentOwner } from './native-chat-attachment-upload' @@ -73,6 +74,10 @@ export function useNativeChatComposerPaste({ async ( owner: NativeChatAttachmentOwner ): Promise<{ status: 'saved'; tempPath: string } | { status: 'empty' | 'failed' }> => { + if (owner.kind === 'runtime') { + setNotice(nativeChatLocalAttachmentUnsupportedNotice()) + return { status: 'failed' } + } try { // SSH panes save the image on the remote host (SFTP) so the attached // path is readable by the remote agent, matching terminal image paste. diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index cc2dc4c9264..3d9017a0c8a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -13,6 +13,8 @@ vi.mock('@/store', () => ({ })) vi.mock('./native-chat-attachment-upload', () => ({ + nativeChatLocalAttachmentUnsupportedNotice: () => + 'Local attachments are not available for remote sessions.', resolveNativeChatAttachmentOwner: mocks.resolveNativeChatAttachmentOwner, uploadNativeChatAttachmentPaths: mocks.uploadNativeChatAttachmentPaths, nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.' @@ -137,6 +139,20 @@ describe('useNativeChatExternalAttachments', () => { expect(attachResolvedPaths).not.toHaveBeenCalled() }) + it('does not attach client-local paths to a remote runtime', async () => { + mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'runtime' }) + const attachResolvedPaths = vi.fn() + const setNotice = vi.fn() + const probe = await renderProbe({ attachResolvedPaths, setNotice }) + await act(async () => { + probe.latest().attachExternalPaths(['/local/a.txt']) + }) + expect(setNotice).toHaveBeenCalledWith( + 'Local attachments are not available for remote sessions.' + ) + expect(attachResolvedPaths).not.toHaveBeenCalled() + }) + it('drops an upload that resolves after the composer became disabled', async () => { mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'ssh', diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts index d2e19375eec..25794e4c128 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts @@ -1,14 +1,17 @@ import { useCallback, useRef } from 'react' import { useAppStore } from '@/store' import { + nativeChatLocalAttachmentUnsupportedNotice, nativeChatWorktreeNotReadyNotice, resolveNativeChatAttachmentOwner, + resolveNativeChatAttachmentOwnerForWorktree, uploadNativeChatAttachmentPaths, type NativeChatAttachmentOwner } from './native-chat-attachment-upload' export type UseNativeChatExternalAttachmentsArgs = { terminalTabId: string + structuredWorktreeId?: string /** Live composer-disabled state; read at await-resume via a ref so a flip * mid-upload doesn't attach into a guarded composer. */ disabled: boolean @@ -23,6 +26,7 @@ export type UseNativeChatExternalAttachmentsArgs = { */ export function useNativeChatExternalAttachments({ terminalTabId, + structuredWorktreeId, disabled, attachResolvedPaths, setNotice @@ -34,8 +38,11 @@ export function useNativeChatExternalAttachments({ disabledRef.current = disabled const resolveAttachmentOwner = useCallback( - () => resolveNativeChatAttachmentOwner(useAppStore.getState(), terminalTabId), - [terminalTabId] + () => + structuredWorktreeId + ? resolveNativeChatAttachmentOwnerForWorktree(useAppStore.getState(), structuredWorktreeId) + : resolveNativeChatAttachmentOwner(useAppStore.getState(), terminalTabId), + [structuredWorktreeId, terminalTabId] ) const attachExternalPaths = useCallback( @@ -48,9 +55,11 @@ export function useNativeChatExternalAttachments({ setNotice(nativeChatWorktreeNotReadyNotice()) return } + if (owner.kind === 'runtime') { + setNotice(nativeChatLocalAttachmentUnsupportedNotice()) + return + } if (owner.kind !== 'ssh') { - // 'runtime' proceeds so attachResolvedPaths' existing remote-session - // gate reports the unsupported state. attachResolvedPaths(paths) return } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx index 6c0b78d5ce7..331948ad9bf 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx @@ -148,4 +148,31 @@ describe('useStructuredAgentSessionOutbox', () => { .clientOperationId ).toBe(firstId) }) + + it('persists and dispatches an attachment-only structured send', async () => { + mocks.call.mockResolvedValue(acceptedResult(1)) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + ) + + act(() => + expect( + result.current.send('', [{ path: '/tmp/image.png', previewUri: 'file:///tmp/image.png' }]) + ).toBe(true) + ) + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + + expect(mocks.call.mock.calls[0]?.[2]).toMatchObject({ + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'image-ref', path: '/tmp/image.png' }] + } + }) + }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 49325667f94..457fcad4b77 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -220,15 +220,15 @@ export function useStructuredAgentSessionOutbox(args: { }, [fence, outbox, sessionId, target]) const send = useCallback( - (text: string): boolean => { - if (!text.trim()) { + (text: string, attachments: readonly { path: string; previewUri: string }[] = []): boolean => { + if (!text.trim() && attachments.length === 0) { return false } const entry = createStructuredAgentSessionOutboxEntry({ clientMessageId: structuredSessionOperationId(), sessionId, text, - attachments: [], + attachments, queuedAt: Date.now() }) const next = [...outboxRef.current, entry] diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index db411ffc9de..94301dc0489 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -18,10 +18,7 @@ import { createStructuredAgentSessionOptionState, structuredAgentSessionOptionSnapshot } from '../../../../shared/structured-agent-session-options' -import { - activeStructuredAgentSessionTurnId, - projectStructuredItemsToNativeChat -} from '../../../../shared/structured-agent-session-projection' +import { activeStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-projection' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { @@ -29,6 +26,7 @@ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' export type StructuredPromptItem = AgentJournalRenderItem & { body: Extract @@ -209,16 +207,11 @@ export function useStructuredAgentSession(args: { ) const turnId = activeStructuredAgentSessionTurnId(state.items) return { - messages: [ - ...projectStructuredItemsToNativeChat(state.items), - ...outboxController.outbox.map((entry) => ({ - id: entry.clientMessageId, - role: 'user' as const, - source: 'transcript' as const, - timestamp: entry.queuedAt, - blocks: entry.body.blocks - })) - ], + messages: projectStructuredAgentSessionMessages( + state.items, + outboxController.outbox, + state.submissions + ), status: state.status, error: state.error ?? writeError ?? outboxController.error, hasOlder: state.hasOlder, diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts index a9ea4e0c275..f94eb769012 100644 --- a/src/shared/structured-agent-session-reducer.test.ts +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -15,6 +15,19 @@ function item(id: string, sequence: number): AgentJournalRenderItem { } } +function submission(index: number) { + return { + clientMessageId: `client-${index}`, + fence: 1, + payloadFingerprint: `fingerprint-${index}`, + dispatchState: 'accepted' as const, + providerItemId: `provider-${index}`, + reason: null, + submittedAt: index, + resolvedAt: index + } +} + describe('structured agent session reducer', () => { it('does not let a stale focus refresh replace newer streamed state', () => { const streamed = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { @@ -53,4 +66,87 @@ describe('structured agent session reducer', () => { expect(afterRefresh).toBe(streamed) }) + + it('keeps rapid-send submissions when a newer tail refresh contains only the last one', () => { + const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + snapshot: { + sessionId: 'session-a', + cursor: { epoch: 'epoch-a', sequence: 10 }, + items: [item('first', 10)], + submissions: Array.from({ length: 8 }, (_, index) => submission(index)) + } + } + }) + const refreshed = reduceStructuredAgentSession(initial, { + type: 'tail-page', + page: { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items: [item('latest', 11)], + removedItemIds: [], + submissions: [submission(7)], + window: { + oldest: { epoch: 'epoch-a', sequence: 11 }, + newest: { epoch: 'epoch-a', sequence: 11 }, + nextCursor: { epoch: 'epoch-a', sequence: 11 } + }, + liveCursor: { epoch: 'epoch-a', sequence: 11 }, + hasOlder: true, + hasNewer: false + } + }) + + expect(refreshed.submissions.map((entry) => entry.clientMessageId)).toEqual( + Array.from({ length: 8 }, (_, index) => `client-${index}`) + ) + }) + + it('bounds retained submission identities across repeated tail refreshes', () => { + let state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + snapshot: { + sessionId: 'session-a', + cursor: { epoch: 'epoch-a', sequence: 1 }, + items: [item('first', 1)], + submissions: [] + } + } + }) + + for (let index = 0; index < 300; index += 1) { + state = reduceStructuredAgentSession(state, { + type: 'tail-page', + page: { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items: [item(`item-${index}`, index + 2)], + removedItemIds: [], + submissions: [submission(index)], + window: { + oldest: { epoch: 'epoch-a', sequence: index + 2 }, + newest: { epoch: 'epoch-a', sequence: index + 2 }, + nextCursor: { epoch: 'epoch-a', sequence: index + 2 } + }, + liveCursor: { epoch: 'epoch-a', sequence: index + 2 }, + hasOlder: true, + hasNewer: false + } + }) + } + + expect(state.submissions).toHaveLength(256) + expect(state.submissions[0]?.clientMessageId).toBe('client-44') + expect(state.submissions.at(-1)?.clientMessageId).toBe('client-299') + }) }) diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 8b5c1a5bb0f..d423a77573a 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -41,6 +41,8 @@ export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = { handoff: null } +const MAX_RETAINED_SUBMISSIONS = 256 + function replaceSnapshot( snapshot: AgentJournalSnapshot, fence: number, @@ -84,7 +86,9 @@ function mergeSubmissions( for (const submission of incoming) { byId.set(submission.clientMessageId, submission) } - return [...byId.values()].sort((left, right) => left.submittedAt - right.submittedAt) + return [...byId.values()] + .sort((left, right) => left.submittedAt - right.submittedAt) + .slice(-MAX_RETAINED_SUBMISSIONS) } export function reduceStructuredAgentSession( @@ -109,12 +113,15 @@ export function reduceStructuredAgentSession( ) { return state } + const sameEpoch = state.epoch === action.page.epoch return { epoch: action.page.epoch, cursor: action.page.liveCursor ?? null, fence: action.page.fence ?? null, items: action.page.items, - submissions: action.page.submissions, + submissions: sameEpoch + ? mergeSubmissions(state.submissions, action.page.submissions) + : action.page.submissions, hasOlder: action.page.hasOlder, status: 'ready', handoff: state.handoff