fix: resolve structured chat overnight anomalies

This commit is contained in:
Brennan Benson
2026-08-14 16:03:16 -07:00
parent 87759c4463
commit b28396e913
27 changed files with 735 additions and 59 deletions
@@ -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<string>
hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set<string>
refreshMobileSessionPtyRecords(): Promise<Set<string> | null>
ensureStructuredAgentSessionHost(): Promise<void>
}
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'])
})
})
+37 -1
View File
@@ -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
})
}
}
@@ -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(
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:structured"
targetPtyId={null}
agent="codex"
structuredTransport={{
send,
dispatchCommand: vi.fn(async () => ({
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(
@@ -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<NativeChatComposerHandle, NativeCha
const { imageAttachments, attachResolvedPaths, clearImageAttachments, removeImageAttachment } =
useNativeChatComposerAttachments({
attachmentScopeKey: paneKey,
allowWithoutTarget: Boolean(structuredTransport),
caret,
resolveTarget,
textareaRef,
@@ -192,6 +196,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
const { attachExternalPaths, resolveAttachmentOwner } = useNativeChatExternalAttachments({
terminalTabId,
structuredWorktreeId: structuredTransport?.worktreeId,
disabled,
attachResolvedPaths,
setNotice
@@ -239,11 +244,15 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
const sessionOptionsSnapshot = structuredTransport?.optionSnapshot ?? ptySessionOptionsSnapshot
const sendStructured = useCallback(
(text: string): void => {
(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<NativeChatComposerHandle, NativeCha
structuredTransport.onError(error instanceof Error ? error.message : String(error))
)
},
[agent, clearImageAttachments, clearSkillOrigin, setDraft, structuredTransport]
[
agent,
clearImageAttachments,
clearSkillOrigin,
imageAttachments,
setDraft,
structuredTransport
]
)
const sendPty = useNativeChatPtyComposerSend({
@@ -289,10 +305,10 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
const send = useCallback(() => {
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<NativeChatComposerHandle, NativeCha
imageAttachments={imageAttachments}
sendButtonDisabled={sendButtonDisabled}
isWorking={isWorking}
attachDisabled={disabled || Boolean(structuredTransport)}
attachDisabled={disabled}
dictationDisabled={dictationDisabled}
isDictating={isDictating}
isDictationHoldMode={isDictationHoldMode}
@@ -419,6 +435,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
onStop={interrupt}
sessionOptionsSurface={sessionOptionsSurface}
sessionOptionsSnapshot={sessionOptionsSnapshot}
sessionOptionsPickerRequest={structuredTransport?.optionPickerRequest ?? null}
/>
)
}
@@ -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}
/>
<Tooltip>
<TooltipTrigger asChild>
@@ -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<HTMLTextAreaElement | null>
@@ -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 (
<div className="shrink-0 bg-background">
@@ -213,6 +216,7 @@ export function NativeChatComposerField({
onStop={onStop}
sessionOptionsSurface={sessionOptionsSurface}
sessionOptionsSnapshot={sessionOptionsSnapshot}
sessionOptionsPickerRequest={sessionOptionsPickerRequest}
/>
</div>
</div>
@@ -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 }) => <div>{children}</div>,
DropdownMenu: ({
children,
defaultOpen
}: {
children: React.ReactNode
defaultOpen?: boolean
}) => (
<div data-testid="dropdown-root" data-open={defaultOpen ? 'true' : 'false'}>
{children}
</div>
),
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(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model(), effort]}
isWorking={false}
pickerRequest={null}
/>
)
rerender(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model(), effort]}
isWorking={false}
pickerRequest={{ id: 'model', sequence: 1 }}
/>
)
await waitFor(() =>
expect(screen.getAllByTestId('dropdown-root')[1]?.getAttribute('data-open')).toBe('true')
)
rerender(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model(), effort]}
isWorking={false}
pickerRequest={{ id: 'effort', sequence: 2 }}
/>
)
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(
<NativeChatSessionOptionPickers
@@ -28,11 +28,13 @@ import {
nativeChatSessionOptionDisabledReason,
nativeChatSessionOptionLabel
} from './native-chat-session-option-labels'
import type { NativeChatOptionPickerRequest } from './native-chat-composer-types'
export type NativeChatSessionOptionPickersProps = {
surface: SessionOptionsSurface | null
snapshot: SessionOptionDescriptor[]
isWorking: boolean
pickerRequest?: NativeChatOptionPickerRequest | null
}
function PickerTooltipContent(props: {
@@ -207,7 +209,8 @@ function runSurfaceCall(
function NativeChatSessionOptionPickersInner({
surface,
snapshot,
isWorking
isWorking,
pickerRequest
}: NativeChatSessionOptionPickersProps): React.JSX.Element | null {
const [pendingId, setPendingId] = useState<string | null>(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 (
<div className="flex min-w-0 items-center gap-0.5">
{options.length > 0 ? (
<DropdownMenu>
<DropdownMenu
key={`options:${requestedOptionsSequence ?? 'idle'}`}
defaultOpen={requestedOptionsSequence !== null}
>
<PickerTrigger
label={nativeChatOptionsPillLabel(options)}
tooltipLabel={optionsTooltip}
@@ -264,7 +274,10 @@ function NativeChatSessionOptionPickersInner({
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu>
<DropdownMenu
key={`model:${requestedModelSequence ?? 'idle'}`}
defaultOpen={requestedModelSequence !== null}
>
<PickerTrigger
label={nativeChatModelPillLabel(model)}
tooltipLabel={modelTooltip}
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { cleanup, render } from '@testing-library/react'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
@@ -8,7 +8,8 @@ const mocks = vi.hoisted(() => ({
messageListProps: null as null | {
allowFileUriLinks?: boolean
onLinkClick?: (...args: unknown[]) => void
}
},
composerProps: null as null | { structuredTransport?: Record<string, unknown> }
}))
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(
<NativeChatStructuredSession
tabId="structured-tab-1"
sessionId="session-1"
target={{ kind: 'local' }}
agent="codex"
allowFileUriLinks
/>
)
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
})
})
})
@@ -31,6 +31,10 @@ export function NativeChatStructuredSession(props: {
}): React.JSX.Element {
const controller = useStructuredAgentSession(props)
const [composerError, setComposerError] = useState<string | null>(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 (
@@ -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(
@@ -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
@@ -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<StructuredAgentSessionCommandOutcome>
optionsSurface: SessionOptionsSurface
optionSnapshot: SessionOptionDescriptor[]
optionPickerRequest?: NativeChatOptionPickerRequest | null
worktreeId?: string
onError: (message: string | null) => void
runtime: 'local' | 'remote'
}
@@ -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 }
}
@@ -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' }
])
})
})
@@ -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
})
)
]
}
@@ -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<HTMLTextAreaElement>(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<void> }> {
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 () => {
@@ -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<HTMLTextAreaElement | null>
@@ -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 {
@@ -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.')
@@ -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.
@@ -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',
@@ -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
}
@@ -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' }]
}
})
})
})
@@ -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]
@@ -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<AgentJournalRenderItem['body'], { kind: 'approval' | 'question' }>
@@ -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,
@@ -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')
})
})
@@ -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