mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
Merge #24917 (a new chat after a failed start; notes held while their send is on its way) into C2
Both add a hook where the outbox commits an entry's end; both are kept here and folded into one next.
This commit is contained in:
+4
-1
@@ -6,13 +6,15 @@ export type BrowserAnnotationSendMenuContentProps = {
|
||||
groupId: string
|
||||
prompt: string
|
||||
onPromptDelivered?: () => void
|
||||
onPromptHandedOff?: (delivered: Promise<unknown>) => void
|
||||
}
|
||||
|
||||
export function BrowserAnnotationSendMenuContent({
|
||||
worktreeId,
|
||||
groupId,
|
||||
prompt,
|
||||
onPromptDelivered
|
||||
onPromptDelivered,
|
||||
onPromptHandedOff
|
||||
}: BrowserAnnotationSendMenuContentProps): React.JSX.Element {
|
||||
return (
|
||||
<ReviewNotesSendMenuContent
|
||||
@@ -24,6 +26,7 @@ export function BrowserAnnotationSendMenuContent({
|
||||
promptDelivery="submit-after-ready"
|
||||
launchSource="notes_send"
|
||||
onPromptDelivered={onPromptDelivered}
|
||||
onPromptHandedOff={onPromptHandedOff}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export function BrowserGuestAnnotateOverlays({
|
||||
activeGroupId,
|
||||
browserAnnotationsPrompt,
|
||||
handleBrowserAnnotationsSentToAgent,
|
||||
handleBrowserAnnotationsHandedOff,
|
||||
handleCopyBrowserAnnotations,
|
||||
browserAnnotationsCopied,
|
||||
handleClearBrowserAnnotations,
|
||||
@@ -119,6 +120,7 @@ export function BrowserGuestAnnotateOverlays({
|
||||
activeGroupId={activeGroupId}
|
||||
browserAnnotationsPrompt={browserAnnotationsPrompt}
|
||||
handleBrowserAnnotationsSentToAgent={handleBrowserAnnotationsSentToAgent}
|
||||
handleBrowserAnnotationsHandedOff={handleBrowserAnnotationsHandedOff}
|
||||
handleCopyBrowserAnnotations={handleCopyBrowserAnnotations}
|
||||
browserAnnotationsCopied={browserAnnotationsCopied}
|
||||
handleClearBrowserAnnotations={handleClearBrowserAnnotations}
|
||||
|
||||
+1
@@ -87,6 +87,7 @@ function renderTray(currentUrl?: string): {
|
||||
activeGroupId={undefined}
|
||||
browserAnnotationsPrompt="prompt"
|
||||
handleBrowserAnnotationsSentToAgent={vi.fn()}
|
||||
handleBrowserAnnotationsHandedOff={vi.fn()}
|
||||
handleCopyBrowserAnnotations={vi.fn()}
|
||||
browserAnnotationsCopied={false}
|
||||
handleClearBrowserAnnotations={vi.fn()}
|
||||
|
||||
@@ -29,6 +29,7 @@ export function BrowserPageAnnotationTray({
|
||||
activeGroupId,
|
||||
browserAnnotationsPrompt,
|
||||
handleBrowserAnnotationsSentToAgent,
|
||||
handleBrowserAnnotationsHandedOff,
|
||||
handleCopyBrowserAnnotations,
|
||||
browserAnnotationsCopied,
|
||||
handleClearBrowserAnnotations,
|
||||
@@ -43,6 +44,7 @@ export function BrowserPageAnnotationTray({
|
||||
activeGroupId: string | undefined
|
||||
browserAnnotationsPrompt: string
|
||||
handleBrowserAnnotationsSentToAgent: () => void
|
||||
handleBrowserAnnotationsHandedOff: (delivered: Promise<unknown>) => void
|
||||
handleCopyBrowserAnnotations: () => void
|
||||
browserAnnotationsCopied: boolean
|
||||
handleClearBrowserAnnotations: () => void
|
||||
@@ -111,7 +113,12 @@ export function BrowserPageAnnotationTray({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="xs" variant="outline" className="gap-1.5">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
disabled={!browserAnnotationsPrompt}
|
||||
>
|
||||
<Send className="size-3" />
|
||||
{translate('auto.components.browser.pane.BrowserPane.ac39b9366b', 'Send')}
|
||||
</Button>
|
||||
@@ -135,6 +142,7 @@ export function BrowserPageAnnotationTray({
|
||||
groupId={activeGroupId ?? worktreeId}
|
||||
prompt={browserAnnotationsPrompt}
|
||||
onPromptDelivered={handleBrowserAnnotationsSentToAgent}
|
||||
onPromptHandedOff={handleBrowserAnnotationsHandedOff}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
+64
@@ -8,6 +8,7 @@ import { createTestStore } from '@/store/slices/browser-slice-test-harness'
|
||||
import type { OpenAgentSendPopoverTargetModeArgs } from '@/store/slices/ui'
|
||||
import { BrowserPageAnnotationTray } from './browser-page-annotation-tray'
|
||||
import { useBrowserPageAnnotationSend } from './use-browser-page-annotation-send'
|
||||
import { resetNotesInFlightForTests } from '@/lib/notes-send-in-flight'
|
||||
|
||||
const state = vi.hoisted((): { store?: ReturnType<typeof createTestStore> } => ({}))
|
||||
vi.mock('@/store', () => ({
|
||||
@@ -93,6 +94,7 @@ beforeEach(() => {
|
||||
store = createTestStore()
|
||||
state.store = store
|
||||
mode = undefined
|
||||
resetNotesInFlightForTests()
|
||||
store.setState({
|
||||
activeGroupIdByWorktree: {},
|
||||
openAgentSendPopoverTargetMode: (next) => {
|
||||
@@ -214,3 +216,65 @@ describe('website annotation delivery', () => {
|
||||
expect(notes()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('website annotations handed to a send', () => {
|
||||
const second = (): BrowserPageAnnotation => ({
|
||||
...makeAnnotation('page-1', 'second'),
|
||||
comment: 'Second note'
|
||||
})
|
||||
|
||||
it('sends only an annotation added while an earlier send is still on its way', async () => {
|
||||
const view = mount()
|
||||
const firstDelivered = view.result.current.handleBrowserAnnotationsSentToAgent
|
||||
let deliverFirst!: (result: { delivered: boolean }) => void
|
||||
act(() =>
|
||||
view.result.current.handleBrowserAnnotationsHandedOff(
|
||||
new Promise((resolve) => (deliverFirst = resolve))
|
||||
)
|
||||
)
|
||||
expect(view.result.current.browserAnnotationsPrompt).toBe('')
|
||||
|
||||
act(() => store.getState().addBrowserPageAnnotation(second()))
|
||||
expect(view.result.current.browserAnnotationsPrompt).toContain('Second note')
|
||||
expect(view.result.current.browserAnnotationsPrompt).not.toContain('Fix this button')
|
||||
const secondDelivered = view.result.current.handleBrowserAnnotationsSentToAgent
|
||||
|
||||
act(firstDelivered)
|
||||
await act(async () => deliverFirst({ delivered: true }))
|
||||
expect(notes().map((note) => note.id)).toEqual(['second'])
|
||||
act(secondDelivered)
|
||||
expect(notes()).toEqual([])
|
||||
})
|
||||
|
||||
it('puts annotations back for the next send when their delivery fails', async () => {
|
||||
const view = mount()
|
||||
const delivered = Promise.resolve({ delivered: false, failureNotified: true })
|
||||
act(() => view.result.current.handleBrowserAnnotationsHandedOff(delivered))
|
||||
expect(view.result.current.browserAnnotationsPrompt).toBe('')
|
||||
|
||||
await act(async () => {
|
||||
await delivered
|
||||
})
|
||||
|
||||
expect(view.result.current.browserAnnotationsPrompt).toContain('Fix this button')
|
||||
expect(notes()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('holds what a running-agent send carries too', () => {
|
||||
const view = mount()
|
||||
act(() => view.result.current.handleAnnotationTraySendOpenChange(true))
|
||||
act(() => mode?.onPromptHandedOff?.(new Promise(() => undefined)))
|
||||
expect(view.result.current.browserAnnotationsPrompt).toBe('')
|
||||
})
|
||||
|
||||
it('offers no send while every annotation is already on its way', () => {
|
||||
const view = mount()
|
||||
act(() => view.result.current.handleBrowserAnnotationsHandedOff(new Promise(() => undefined)))
|
||||
|
||||
act(() => view.result.current.handleAnnotationTraySendOpenChange(true))
|
||||
expect(mode).toBeUndefined()
|
||||
|
||||
render(<AnnotationTrayHarness />)
|
||||
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
+36
-7
@@ -16,6 +16,11 @@ import type {
|
||||
} from '../../../../../shared/browser-grab-types'
|
||||
import { formatBrowserAnnotationsAsMarkdown } from './browser-annotation-output'
|
||||
import { EMPTY_BROWSER_ANNOTATIONS } from '../describe-page/browser-annotation-geometry'
|
||||
import {
|
||||
holdNotesForSend,
|
||||
isNoteInFlight,
|
||||
useNotesInFlightVersion
|
||||
} from '@/lib/notes-send-in-flight'
|
||||
|
||||
export function useBrowserPageAnnotationSend({
|
||||
browserTabId,
|
||||
@@ -42,6 +47,7 @@ export function useBrowserPageAnnotationSend({
|
||||
intent: BrowserAnnotationIntent
|
||||
) => void
|
||||
handleBrowserAnnotationsSentToAgent: () => void
|
||||
handleBrowserAnnotationsHandedOff: (delivered: Promise<unknown>) => void
|
||||
activeGroupId: string | undefined
|
||||
} {
|
||||
const browserAnnotations = useAppStore(
|
||||
@@ -52,10 +58,20 @@ export function useBrowserPageAnnotationSend({
|
||||
const [browserAnnotationTrayOpen, setBrowserAnnotationTrayOpen] = useState(true)
|
||||
const [browserAnnotationsCopied, setBrowserAnnotationsCopied] = useState(false)
|
||||
const annotationCopyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const browserAnnotationsPrompt = useMemo(
|
||||
const copyPrompt = useMemo(
|
||||
() => formatBrowserAnnotationsAsMarkdown(browserAnnotations),
|
||||
[browserAnnotations]
|
||||
)
|
||||
// Annotations another send holds are left out of the next one.
|
||||
const inFlightVersion = useNotesInFlightVersion()
|
||||
const sendableAnnotations = useMemo(() => {
|
||||
void inFlightVersion
|
||||
return browserAnnotations.filter((annotation) => !isNoteInFlight(annotation))
|
||||
}, [browserAnnotations, inFlightVersion])
|
||||
const browserAnnotationsPrompt = useMemo(
|
||||
() => formatBrowserAnnotationsAsMarkdown(sendableAnnotations),
|
||||
[sendableAnnotations]
|
||||
)
|
||||
const openAgentSendPopoverTargetMode = useAppStore((s) => s.openAgentSendPopoverTargetMode)
|
||||
const closeAgentSendPopoverTargetMode = useAppStore((s) => s.closeAgentSendPopoverTargetMode)
|
||||
const activeAgentSendTargetModeId = useAppStore((s) => s.agentSendPopoverTargetMode?.id ?? null)
|
||||
@@ -82,26 +98,32 @@ export function useBrowserPageAnnotationSend({
|
||||
}, [])
|
||||
|
||||
const handleCopyBrowserAnnotations = useCallback((): void => {
|
||||
if (!browserAnnotationsPrompt) {
|
||||
if (!copyPrompt) {
|
||||
return
|
||||
}
|
||||
void window.api.ui.writeClipboardText(browserAnnotationsPrompt)
|
||||
void window.api.ui.writeClipboardText(copyPrompt)
|
||||
recordFeatureInteraction('browser-annotations')
|
||||
clearTimeout(annotationCopyTimerRef.current)
|
||||
setBrowserAnnotationsCopied(true)
|
||||
annotationCopyTimerRef.current = setTimeout(() => setBrowserAnnotationsCopied(false), 1400)
|
||||
}, [browserAnnotationsPrompt, recordFeatureInteraction])
|
||||
}, [copyPrompt, recordFeatureInteraction])
|
||||
|
||||
const handleBrowserAnnotationsSentToAgent = useCallback((): void => {
|
||||
recordFeatureInteraction('browser-annotations-sent-to-agent')
|
||||
removeDeliveredBrowserPageAnnotations(browserTabId, browserAnnotations)
|
||||
removeDeliveredBrowserPageAnnotations(browserTabId, sendableAnnotations)
|
||||
}, [
|
||||
browserAnnotations,
|
||||
sendableAnnotations,
|
||||
browserTabId,
|
||||
recordFeatureInteraction,
|
||||
removeDeliveredBrowserPageAnnotations
|
||||
])
|
||||
|
||||
const handleBrowserAnnotationsHandedOff = useCallback(
|
||||
(delivered: Promise<unknown>): void =>
|
||||
holdNotesForSend(sendableAnnotations, delivered, handleBrowserAnnotationsSentToAgent),
|
||||
[handleBrowserAnnotationsSentToAgent, sendableAnnotations]
|
||||
)
|
||||
|
||||
const handleClearBrowserAnnotations = useCallback((): void => {
|
||||
if (browserAnnotationsRef.current.length === 0) {
|
||||
return
|
||||
@@ -115,6 +137,10 @@ export function useBrowserPageAnnotationSend({
|
||||
const handleAnnotationSendOpenChange = useCallback(
|
||||
(modeId: string, open: boolean): void => {
|
||||
if (open) {
|
||||
// Every annotation may already be on its way: there is nothing to send.
|
||||
if (!browserAnnotationsPrompt) {
|
||||
return
|
||||
}
|
||||
openAgentSendPopoverTargetMode({
|
||||
id: modeId,
|
||||
worktreeId,
|
||||
@@ -125,7 +151,8 @@ export function useBrowserPageAnnotationSend({
|
||||
'Browser annotations'
|
||||
),
|
||||
launchSource: 'notes_send',
|
||||
onPromptDelivered: handleBrowserAnnotationsSentToAgent
|
||||
onPromptDelivered: handleBrowserAnnotationsSentToAgent,
|
||||
onPromptHandedOff: handleBrowserAnnotationsHandedOff
|
||||
})
|
||||
} else {
|
||||
closeAgentSendPopoverTargetMode(modeId)
|
||||
@@ -134,6 +161,7 @@ export function useBrowserPageAnnotationSend({
|
||||
[
|
||||
browserAnnotationsPrompt,
|
||||
handleBrowserAnnotationsSentToAgent,
|
||||
handleBrowserAnnotationsHandedOff,
|
||||
closeAgentSendPopoverTargetMode,
|
||||
openAgentSendPopoverTargetMode,
|
||||
worktreeId
|
||||
@@ -199,6 +227,7 @@ export function useBrowserPageAnnotationSend({
|
||||
handleDeleteBrowserAnnotation,
|
||||
handleUpdateBrowserAnnotation,
|
||||
handleBrowserAnnotationsSentToAgent,
|
||||
handleBrowserAnnotationsHandedOff,
|
||||
activeGroupId
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -28,6 +28,7 @@ export function BrowserPageChromeBanners({
|
||||
activeGroupId,
|
||||
browserAnnotationsPrompt,
|
||||
handleBrowserAnnotationsSentToAgent,
|
||||
handleBrowserAnnotationsHandedOff,
|
||||
handleCopyBrowserAnnotations,
|
||||
browserAnnotationsCopied,
|
||||
handleClearBrowserAnnotations,
|
||||
@@ -45,6 +46,7 @@ export function BrowserPageChromeBanners({
|
||||
activeGroupId: string | undefined
|
||||
browserAnnotationsPrompt: string
|
||||
handleBrowserAnnotationsSentToAgent: () => void
|
||||
handleBrowserAnnotationsHandedOff: (delivered: Promise<unknown>) => void
|
||||
handleCopyBrowserAnnotations: () => void
|
||||
browserAnnotationsCopied: boolean
|
||||
handleClearBrowserAnnotations: () => void
|
||||
@@ -131,7 +133,12 @@ export function BrowserPageChromeBanners({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="xs" variant="outline" className="h-6 gap-1.5">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="h-6 gap-1.5"
|
||||
disabled={!browserAnnotationsPrompt}
|
||||
>
|
||||
<Send className="size-3" />
|
||||
{translate('auto.components.browser.pane.BrowserPane.ac39b9366b', 'Send')}
|
||||
</Button>
|
||||
@@ -155,6 +162,7 @@ export function BrowserPageChromeBanners({
|
||||
groupId={activeGroupId ?? worktreeId}
|
||||
prompt={browserAnnotationsPrompt}
|
||||
onPromptDelivered={handleBrowserAnnotationsSentToAgent}
|
||||
onPromptHandedOff={handleBrowserAnnotationsHandedOff}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
+1
@@ -121,6 +121,7 @@ export function BrowserPageChromeHeader({
|
||||
activeGroupId={annotationSend.activeGroupId}
|
||||
browserAnnotationsPrompt={annotationSend.browserAnnotationsPrompt}
|
||||
handleBrowserAnnotationsSentToAgent={annotationSend.handleBrowserAnnotationsSentToAgent}
|
||||
handleBrowserAnnotationsHandedOff={annotationSend.handleBrowserAnnotationsHandedOff}
|
||||
handleCopyBrowserAnnotations={annotationSend.handleCopyBrowserAnnotations}
|
||||
browserAnnotationsCopied={annotationSend.browserAnnotationsCopied}
|
||||
handleClearBrowserAnnotations={annotationSend.handleClearBrowserAnnotations}
|
||||
|
||||
@@ -39,7 +39,8 @@ function getSingleCommentSendScopes(
|
||||
'This note'
|
||||
),
|
||||
notes: comment.sentAt ? [] : [comment],
|
||||
prompt: formatCommentPrompt ? formatCommentPrompt(comment) : formatDiffComments([comment])
|
||||
formatPrompt: () =>
|
||||
formatCommentPrompt ? formatCommentPrompt(comment) : formatDiffComments([comment])
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -54,20 +54,18 @@ export function DiffNotesSendMenu({
|
||||
[consumeOpenRequest, worktreeId]
|
||||
)
|
||||
const unsentNotes = useMemo(() => comments.filter((comment) => !comment.sentAt), [comments])
|
||||
const unsentPrompt = useMemo(() => formatDiffComments(unsentNotes), [unsentNotes])
|
||||
const fileNotes = useMemo(
|
||||
() => (filePath ? comments.filter((comment) => comment.filePath === filePath) : []),
|
||||
[comments, filePath]
|
||||
)
|
||||
const unsentFileNotes = useMemo(() => fileNotes.filter((comment) => !comment.sentAt), [fileNotes])
|
||||
const unsentFilePrompt = useMemo(() => formatDiffComments(unsentFileNotes), [unsentFileNotes])
|
||||
const canSendFileScope = showFileScope && Boolean(filePath)
|
||||
const scopes = useMemo<NotesSendMenuScope<DiffComment>[]>(() => {
|
||||
const allNotesScope = {
|
||||
id: 'all',
|
||||
label: translate('auto.components.editor.DiffNotesSendMenu.8b87612461', 'All unsent notes'),
|
||||
notes: unsentNotes,
|
||||
prompt: unsentPrompt
|
||||
formatPrompt: formatDiffComments
|
||||
}
|
||||
if (!canSendFileScope) {
|
||||
return [allNotesScope]
|
||||
@@ -77,11 +75,11 @@ export function DiffNotesSendMenu({
|
||||
id: 'file',
|
||||
label: translate('auto.components.editor.DiffNotesSendMenu.f1aa04b5cf', 'This file'),
|
||||
notes: unsentFileNotes,
|
||||
prompt: unsentFilePrompt
|
||||
formatPrompt: formatDiffComments
|
||||
},
|
||||
allNotesScope
|
||||
]
|
||||
}, [canSendFileScope, unsentFileNotes, unsentFilePrompt, unsentNotes, unsentPrompt])
|
||||
}, [canSendFileScope, unsentFileNotes, unsentNotes])
|
||||
|
||||
return (
|
||||
<NotesSendMenu
|
||||
|
||||
@@ -32,7 +32,7 @@ export function MarkdownPreviewSingleNoteSendMenu({
|
||||
id: 'note',
|
||||
label: translate('auto.components.editor.MarkdownPreview.f37b98999e', 'This note'),
|
||||
notes: note.sentAt ? [] : [note],
|
||||
prompt: formatMarkdownReviewNotes([note], content)
|
||||
formatPrompt: (notes) => formatMarkdownReviewNotes(notes, content)
|
||||
}
|
||||
]}
|
||||
targetModeLabel="This note"
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildNotesSendTargetModeId, NotesSendMenu } from './NotesSendMenu'
|
||||
import type { DiffCommentDeliverySnapshot } from '@/store/slices/diffComments'
|
||||
import { resetNotesInFlightForTests } from '@/lib/notes-send-in-flight'
|
||||
|
||||
type ReactElementLike = {
|
||||
type: unknown
|
||||
props: Record<string, unknown>
|
||||
}
|
||||
|
||||
type TestNote = {
|
||||
id: string
|
||||
type TestNote = DiffCommentDeliverySnapshot
|
||||
|
||||
function note(id: string): TestNote {
|
||||
return { id, body: `body of ${id}`, filePath: 'README.md', lineNumber: 1 }
|
||||
}
|
||||
|
||||
const hookRuntime = vi.hoisted(() => ({
|
||||
@@ -41,6 +45,9 @@ vi.mock('react', async () => {
|
||||
useMemo<T>(factory: () => T): T {
|
||||
return factory()
|
||||
},
|
||||
useSyncExternalStore<T>(_subscribe: unknown, getSnapshot: () => T): T {
|
||||
return getSnapshot()
|
||||
},
|
||||
useState<T>(initial: T | (() => T)) {
|
||||
const stateIndex = hookRuntime.index++
|
||||
if (!(stateIndex in hookRuntime.states)) {
|
||||
@@ -233,8 +240,8 @@ function renderMenu(
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All unsent notes',
|
||||
notes: [{ id: 'note-1' }],
|
||||
prompt: 'prompt-all'
|
||||
notes: [note('note-1')],
|
||||
formatPrompt: () => 'prompt-all'
|
||||
}
|
||||
]}
|
||||
onDelivered={vi.fn()}
|
||||
@@ -274,11 +281,12 @@ describe('NotesSendMenu', () => {
|
||||
storeMocks.openAgentSendPopoverTargetMode.mockReset()
|
||||
storeMocks.closeAgentSendPopoverTargetMode.mockReset()
|
||||
storeMocks.state.agentSendPopoverTargetMode = null
|
||||
resetNotesInFlightForTests()
|
||||
})
|
||||
|
||||
it('disables the trigger when no scope has deliverable notes', () => {
|
||||
const tree = renderMenu({
|
||||
scopes: [{ id: 'all', label: 'All unsent notes', notes: [], prompt: '' }]
|
||||
scopes: [{ id: 'all', label: 'All unsent notes', notes: [], formatPrompt: () => '' }]
|
||||
})
|
||||
|
||||
expect(findByType(tree, 'button').props.disabled).toBe(true)
|
||||
@@ -288,7 +296,7 @@ describe('NotesSendMenu', () => {
|
||||
|
||||
it('uses caller-provided disabled tooltip copy for disabled note actions', () => {
|
||||
const tree = renderMenu({
|
||||
scopes: [{ id: 'note', label: 'This note', notes: [], prompt: '' }],
|
||||
scopes: [{ id: 'note', label: 'This note', notes: [], formatPrompt: () => '' }],
|
||||
disabledTooltip: 'Note already sent'
|
||||
})
|
||||
|
||||
@@ -317,7 +325,7 @@ describe('NotesSendMenu', () => {
|
||||
const delivered = storeMocks.openAgentSendPopoverTargetMode.mock.calls[0][0]
|
||||
.onPromptDelivered as () => void
|
||||
delivered()
|
||||
expect(onDelivered).toHaveBeenCalledWith([{ id: 'note-1' }])
|
||||
expect(onDelivered).toHaveBeenCalledWith([note('note-1')])
|
||||
|
||||
;(dropdown.props.onOpenChange as (open: boolean) => void)(false)
|
||||
expect(storeMocks.closeAgentSendPopoverTargetMode).toHaveBeenCalledWith(
|
||||
@@ -341,8 +349,18 @@ describe('NotesSendMenu', () => {
|
||||
const tree = renderMenu({
|
||||
defaultScopeId: 'file',
|
||||
scopes: [
|
||||
{ id: 'file', label: 'This file', notes: [{ id: 'file-note' }], prompt: 'prompt-file' },
|
||||
{ id: 'all', label: 'All unsent notes', notes: [{ id: 'all-note' }], prompt: 'prompt-all' }
|
||||
{
|
||||
id: 'file',
|
||||
label: 'This file',
|
||||
notes: [note('file-note')],
|
||||
formatPrompt: () => 'prompt-file'
|
||||
},
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All unsent notes',
|
||||
notes: [note('all-note')],
|
||||
formatPrompt: () => 'prompt-all'
|
||||
}
|
||||
]
|
||||
})
|
||||
const [fileTrigger, allTrigger] = findAllByType(tree, 'DropdownMenuSubTrigger')
|
||||
@@ -375,7 +393,7 @@ describe('NotesSendMenu', () => {
|
||||
renderMenu({
|
||||
openRequestNonce: 1,
|
||||
onOpenRequestHandled,
|
||||
scopes: [{ id: 'all', label: 'All unsent notes', notes: [], prompt: '' }]
|
||||
scopes: [{ id: 'all', label: 'All unsent notes', notes: [], formatPrompt: () => '' }]
|
||||
})
|
||||
|
||||
expect(storeMocks.openAgentSendPopoverTargetMode).not.toHaveBeenCalled()
|
||||
@@ -437,3 +455,121 @@ describe('NotesSendMenu', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NotesSendMenu notes in flight', () => {
|
||||
const noteA = note('note-a')
|
||||
const noteB = note('note-b')
|
||||
const scopeOf = (notes: TestNote[]) => [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All unsent notes',
|
||||
notes,
|
||||
formatPrompt: (sent: readonly TestNote[]) => sent.map((entry) => entry.id).join('+')
|
||||
}
|
||||
]
|
||||
/** Calls a rendered callback prop, failing the test if it is missing. */
|
||||
const invoke = (props: Record<string, unknown>, name: string, ...args: unknown[]): unknown => {
|
||||
const callback = props[name]
|
||||
if (typeof callback !== 'function') {
|
||||
throw new Error(`${name} is not a function`)
|
||||
}
|
||||
return callback(...args)
|
||||
}
|
||||
const contentProps = (tree: unknown) => {
|
||||
const props = findByType(tree, 'ReviewNotesSendMenuContent').props
|
||||
return {
|
||||
prompt: props.prompt,
|
||||
onPromptDelivered: () => invoke(props, 'onPromptDelivered'),
|
||||
onPromptHandedOff: (delivered: Promise<unknown>) =>
|
||||
invoke(props, 'onPromptHandedOff', delivered)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetHookRuntime()
|
||||
storeMocks.openAgentSendPopoverTargetMode.mockReset()
|
||||
storeMocks.state.agentSendPopoverTargetMode = null
|
||||
resetNotesInFlightForTests()
|
||||
})
|
||||
|
||||
it('sends only a note added while an earlier send to a new agent is still on its way', async () => {
|
||||
const onDelivered = vi.fn()
|
||||
let deliverA!: (result: { delivered: boolean }) => void
|
||||
const first = contentProps(renderMenu({ scopes: scopeOf([noteA]), onDelivered }))
|
||||
first.onPromptHandedOff(new Promise((resolve) => (deliverA = resolve)))
|
||||
|
||||
const second = contentProps(renderMenu({ scopes: scopeOf([noteA, noteB]), onDelivered }))
|
||||
expect(second.prompt).toBe('note-b')
|
||||
second.onPromptHandedOff(new Promise(() => undefined))
|
||||
|
||||
first.onPromptDelivered()
|
||||
deliverA({ delivered: true })
|
||||
second.onPromptDelivered()
|
||||
await Promise.resolve()
|
||||
|
||||
// Each send clears only its own note; a repeated clear of A is a no-op for its owner.
|
||||
expect(onDelivered).not.toHaveBeenCalledWith([noteA, noteB])
|
||||
expect(onDelivered).toHaveBeenCalledWith([noteA])
|
||||
expect(onDelivered).toHaveBeenCalledWith([noteB])
|
||||
})
|
||||
|
||||
it('leaves the notes out of the running-agent target mode too', () => {
|
||||
contentProps(renderMenu({ scopes: scopeOf([noteA]) })).onPromptHandedOff(
|
||||
new Promise(() => undefined)
|
||||
)
|
||||
|
||||
const tree = renderMenu({ scopes: scopeOf([noteA, noteB]) })
|
||||
invoke(findByType(tree, 'DropdownMenu').props, 'onOpenChange', true)
|
||||
|
||||
expect(storeMocks.openAgentSendPopoverTargetMode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prompt: 'note-b', onPromptHandedOff: expect.any(Function) })
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an undelivered result', () => Promise.resolve({ delivered: false, failureNotified: true })],
|
||||
['a failed start', () => Promise.reject(new Error('refused'))]
|
||||
])('puts the notes back for the next send after %s', async (_name, deliver) => {
|
||||
const delivered = deliver()
|
||||
contentProps(renderMenu({ scopes: scopeOf([noteA]) })).onPromptHandedOff(delivered)
|
||||
expect(contentProps(renderMenu({ scopes: scopeOf([noteA]) })).prompt).toBe('')
|
||||
|
||||
await delivered.catch(() => undefined)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(contentProps(renderMenu({ scopes: scopeOf([noteA]) })).prompt).toBe('note-a')
|
||||
})
|
||||
|
||||
it('offers no send once every note is on its way', () => {
|
||||
contentProps(renderMenu({ scopes: scopeOf([noteA]) })).onPromptHandedOff(
|
||||
new Promise(() => undefined)
|
||||
)
|
||||
|
||||
const tree = renderMenu({ scopes: scopeOf([noteA]) })
|
||||
expect(findByType(tree, 'button').props.disabled).toBe(true)
|
||||
invoke(findByType(tree, 'DropdownMenu').props, 'onOpenChange', true)
|
||||
expect(storeMocks.openAgentSendPopoverTargetMode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('says the notes are on their way, not sent, while every note is held', () => {
|
||||
contentProps(
|
||||
renderMenu({ scopes: scopeOf([noteA]), disabledTooltip: 'Note already sent' })
|
||||
).onPromptHandedOff(new Promise(() => undefined))
|
||||
|
||||
const tree = renderMenu({ scopes: scopeOf([noteA]), disabledTooltip: 'Note already sent' })
|
||||
|
||||
expect(findByType(tree, 'button').props.title).toBe('Sending…')
|
||||
})
|
||||
|
||||
// A failed new chat's Retry delivers them after the send's own callback is gone.
|
||||
it('clears notes whose send reports delivery later', async () => {
|
||||
const onDelivered = vi.fn()
|
||||
const delivered = Promise.resolve({ delivered: true })
|
||||
contentProps(renderMenu({ scopes: scopeOf([noteA]), onDelivered })).onPromptHandedOff(delivered)
|
||||
|
||||
await delivered
|
||||
await Promise.resolve()
|
||||
|
||||
expect(onDelivered).toHaveBeenCalledWith([noteA])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,13 @@ import {
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ReviewNotesSendMenuContent } from './ReviewNotesSendMenuContent'
|
||||
import type { DiffCommentDeliverySnapshot } from '@/store/slices/diffComments'
|
||||
import {
|
||||
diffCommentSendKey,
|
||||
holdNotesForSend,
|
||||
isNoteInFlight,
|
||||
useNotesInFlightVersion
|
||||
} from '@/lib/notes-send-in-flight'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const ENABLED_SEND_TOOLTIP = 'Send notes to an agent'
|
||||
@@ -22,6 +29,11 @@ export type NotesSendMenuScope<TNote> = {
|
||||
id: string
|
||||
label: string
|
||||
notes: readonly TNote[]
|
||||
/** The prompt for the notes this send carries: notes another send holds are left out. */
|
||||
formatPrompt: (notes: readonly TNote[]) => string
|
||||
}
|
||||
|
||||
type SendableNotesScope<TNote> = Omit<NotesSendMenuScope<TNote>, 'formatPrompt'> & {
|
||||
prompt: string
|
||||
}
|
||||
|
||||
@@ -57,7 +69,7 @@ export function buildNotesSendTargetModeId(modeIdParts: readonly string[]): stri
|
||||
return `note-send:${modeIdParts.map((part) => `${part.length}:${part}`).join('|')}`
|
||||
}
|
||||
|
||||
export function NotesSendMenu<TNote>({
|
||||
export function NotesSendMenu<TNote extends DiffCommentDeliverySnapshot>({
|
||||
worktreeId,
|
||||
groupId,
|
||||
modeIdParts,
|
||||
@@ -82,12 +94,27 @@ export function NotesSendMenu<TNote>({
|
||||
const activeTargetModeId = useAppStore((s) => s.agentSendPopoverTargetMode?.id ?? null)
|
||||
const [sendMenuOpen, setSendMenuOpen] = useState(false)
|
||||
const targetModeId = useMemo(() => buildNotesSendTargetModeId(modeIdParts), [modeIdParts])
|
||||
const enabledScopes = useMemo(() => scopes.filter((scope) => scope.notes.length > 0), [scopes])
|
||||
const inFlightVersion = useNotesInFlightVersion()
|
||||
const sendableScopes = useMemo<SendableNotesScope<TNote>[]>(() => {
|
||||
void inFlightVersion
|
||||
return scopes.map(({ formatPrompt, ...scope }) => {
|
||||
const notes = scope.notes.filter((note) => !isNoteInFlight(diffCommentSendKey(note)))
|
||||
return { ...scope, notes, prompt: notes.length > 0 ? formatPrompt(notes) : '' }
|
||||
})
|
||||
}, [inFlightVersion, scopes])
|
||||
const enabledScopes = useMemo(
|
||||
() => sendableScopes.filter((scope) => scope.notes.length > 0),
|
||||
[sendableScopes]
|
||||
)
|
||||
const defaultScope = useMemo(() => {
|
||||
const requested = enabledScopes.find((scope) => scope.id === defaultScopeId)
|
||||
return requested ?? enabledScopes[0] ?? null
|
||||
}, [defaultScopeId, enabledScopes])
|
||||
const hasDeliverableNotes = enabledScopes.length > 0
|
||||
// Notes only on their way to an agent are not sent yet.
|
||||
const disabledTitle = scopes.some((scope) => scope.notes.length > 0)
|
||||
? translate('components.native-chat.question.sending', 'Sending…')
|
||||
: disabledTooltip
|
||||
|
||||
const markDelivered = useCallback(
|
||||
(notes: readonly TNote[]) => {
|
||||
@@ -95,9 +122,14 @@ export function NotesSendMenu<TNote>({
|
||||
},
|
||||
[onDelivered]
|
||||
)
|
||||
const holdInFlight = useCallback(
|
||||
(notes: readonly TNote[]) => (delivered: Promise<unknown>) =>
|
||||
holdNotesForSend(notes.map(diffCommentSendKey), delivered, () => markDelivered(notes)),
|
||||
[markDelivered]
|
||||
)
|
||||
|
||||
const openTargetMode = useCallback(
|
||||
(scope: NotesSendMenuScope<TNote>) => {
|
||||
(scope: SendableNotesScope<TNote>) => {
|
||||
if (scope.notes.length === 0) {
|
||||
return
|
||||
}
|
||||
@@ -108,10 +140,12 @@ export function NotesSendMenu<TNote>({
|
||||
prompt: scope.prompt,
|
||||
label: targetModeLabel ?? scope.label,
|
||||
launchSource: 'notes_send',
|
||||
onPromptDelivered: () => markDelivered(scope.notes)
|
||||
onPromptDelivered: () => markDelivered(scope.notes),
|
||||
onPromptHandedOff: holdInFlight(scope.notes)
|
||||
})
|
||||
},
|
||||
[
|
||||
holdInFlight,
|
||||
markDelivered,
|
||||
openAgentSendPopoverTargetMode,
|
||||
source,
|
||||
@@ -181,7 +215,7 @@ export function NotesSendMenu<TNote>({
|
||||
triggerClassName
|
||||
)}
|
||||
disabled={!hasDeliverableNotes}
|
||||
title={hasDeliverableNotes ? ENABLED_SEND_TOOLTIP : disabledTooltip}
|
||||
title={hasDeliverableNotes ? ENABLED_SEND_TOOLTIP : disabledTitle}
|
||||
aria-label={
|
||||
triggerLabel
|
||||
? translate(
|
||||
@@ -212,7 +246,7 @@ export function NotesSendMenu<TNote>({
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{hasDeliverableNotes ? ENABLED_SEND_TOOLTIP : disabledTooltip}
|
||||
{hasDeliverableNotes ? ENABLED_SEND_TOOLTIP : disabledTitle}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
@@ -226,7 +260,7 @@ export function NotesSendMenu<TNote>({
|
||||
<DropdownMenuLabel>
|
||||
{translate('auto.components.editor.NotesSendMenu.44dc5e60a6', 'Send notes')}
|
||||
</DropdownMenuLabel>
|
||||
{scopes.map((scope) => (
|
||||
{sendableScopes.map((scope) => (
|
||||
<DropdownMenuSub key={scope.id}>
|
||||
<DropdownMenuSubTrigger
|
||||
disabled={scope.notes.length === 0}
|
||||
@@ -244,6 +278,7 @@ export function NotesSendMenu<TNote>({
|
||||
promptDelivery="submit-after-ready"
|
||||
launchSource="notes_send"
|
||||
onPromptDelivered={() => markDelivered(scope.notes)}
|
||||
onPromptHandedOff={holdInFlight(scope.notes)}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
@@ -261,6 +296,7 @@ export function NotesSendMenu<TNote>({
|
||||
markDelivered(defaultScope.notes)
|
||||
}
|
||||
}}
|
||||
onPromptHandedOff={holdInFlight(defaultScope?.notes ?? [])}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -650,6 +650,7 @@ describe('ReviewNotesSendMenuContent', () => {
|
||||
it('sends notes to the chosen agent and tracks the send once it succeeds', async () => {
|
||||
const statusPaneKey = makePaneKey(TAB_A, LEAF_A)
|
||||
const onPromptDelivered = vi.fn()
|
||||
const onPromptHandedOff = vi.fn()
|
||||
setStore({
|
||||
tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] },
|
||||
terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') }
|
||||
@@ -665,7 +666,7 @@ describe('ReviewNotesSendMenuContent', () => {
|
||||
}
|
||||
]
|
||||
|
||||
const tree = render({ onPromptDelivered })
|
||||
const tree = render({ onPromptDelivered, onPromptHandedOff })
|
||||
;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)()
|
||||
await flushMicrotasks()
|
||||
|
||||
@@ -680,6 +681,10 @@ describe('ReviewNotesSendMenuContent', () => {
|
||||
launch_source: 'notes_send',
|
||||
request_kind: 'followup'
|
||||
})
|
||||
// The notes are held from the hand-off until this send's own outcome, after its delivery.
|
||||
expect(onPromptHandedOff).toHaveBeenCalledOnce()
|
||||
await onPromptHandedOff.mock.calls[0][0]
|
||||
expect(onPromptDelivered).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps selected-target note failures undelivered and uses selected wording', async () => {
|
||||
@@ -854,13 +859,23 @@ describe('ReviewNotesSendMenuContent', () => {
|
||||
})
|
||||
|
||||
it('always offers the new-agent launcher', () => {
|
||||
const tree = render()
|
||||
const onPromptHandedOff = vi.fn()
|
||||
const tree = render({ onPromptHandedOff })
|
||||
|
||||
expect(findByType(tree, 'QuickLaunchAgentMenuItems').props).toMatchObject({
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1',
|
||||
prompt: 'my notes',
|
||||
launchSource: 'notes_send'
|
||||
launchSource: 'notes_send',
|
||||
onPromptHandedOff
|
||||
})
|
||||
})
|
||||
|
||||
// Every note already on its way leaves an empty prompt: no agent may start with no text.
|
||||
it('disables the new-agent launcher when there is nothing left to send', () => {
|
||||
expect(findByType(render({ prompt: '' }), 'QuickLaunchAgentMenuItems').props.disabled).toBe(
|
||||
true
|
||||
)
|
||||
expect(findByType(render(), 'QuickLaunchAgentMenuItems').props.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,7 +46,8 @@ export function ReviewNotesSendMenuContent({
|
||||
prompt,
|
||||
promptDelivery = 'submit-after-ready',
|
||||
launchSource = 'notes_send',
|
||||
onPromptDelivered
|
||||
onPromptDelivered,
|
||||
onPromptHandedOff
|
||||
}: {
|
||||
worktreeId: string
|
||||
groupId: string
|
||||
@@ -54,6 +55,8 @@ export function ReviewNotesSendMenuContent({
|
||||
promptDelivery?: 'auto-submit' | 'draft' | 'submit-after-ready'
|
||||
launchSource?: LaunchSource
|
||||
onPromptDelivered?: () => void
|
||||
/** Given each send's own result the moment its prompt is handed to an agent. */
|
||||
onPromptHandedOff?: (delivered: Promise<unknown>) => void
|
||||
}): React.JSX.Element {
|
||||
const hasPrompt = prompt.trim().length > 0
|
||||
|
||||
@@ -114,7 +117,7 @@ export function ReviewNotesSendMenuContent({
|
||||
)
|
||||
)
|
||||
|
||||
void send()
|
||||
const sending = send()
|
||||
.then((result) => {
|
||||
if (result.status === 'sent') {
|
||||
onSent()
|
||||
@@ -147,8 +150,9 @@ export function ReviewNotesSendMenuContent({
|
||||
{ id: pending }
|
||||
)
|
||||
})
|
||||
onPromptHandedOff?.(sending)
|
||||
},
|
||||
[]
|
||||
[onPromptHandedOff]
|
||||
)
|
||||
|
||||
const sendToAgentTarget = useCallback(
|
||||
@@ -208,6 +212,8 @@ export function ReviewNotesSendMenuContent({
|
||||
promptDelivery={promptDelivery}
|
||||
launchSource={launchSource}
|
||||
onPromptDelivered={onPromptDelivered}
|
||||
onPromptHandedOff={onPromptHandedOff}
|
||||
disabled={!hasPrompt}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -134,10 +134,8 @@ export function RichMarkdownReviewNoteLayer({
|
||||
'This note'
|
||||
),
|
||||
notes: comment.sentAt ? [] : [comment as MarkdownReviewNote],
|
||||
prompt: formatMarkdownReviewNotes(
|
||||
[comment as MarkdownReviewNote],
|
||||
markdownReviewContent
|
||||
)
|
||||
formatPrompt: (notes) =>
|
||||
formatMarkdownReviewNotes(notes, markdownReviewContent)
|
||||
}
|
||||
]}
|
||||
targetModeLabel="This note"
|
||||
|
||||
@@ -101,20 +101,16 @@ export function useMarkdownPreviewFoundation({
|
||||
() => markdownReviewNotes.filter((note) => !note.sentAt),
|
||||
[markdownReviewNotes]
|
||||
)
|
||||
const unsentMarkdownReviewPrompt = useMemo(
|
||||
() => formatMarkdownReviewNotes(unsentMarkdownReviewNotes, renderedContent),
|
||||
[renderedContent, unsentMarkdownReviewNotes]
|
||||
)
|
||||
const unsentMarkdownReviewScope = useMemo<NotesSendMenuScope<MarkdownReviewNote>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'all',
|
||||
label: translate('auto.components.editor.MarkdownPreview.ddf087d12e', 'All unsent notes'),
|
||||
notes: unsentMarkdownReviewNotes,
|
||||
prompt: unsentMarkdownReviewPrompt
|
||||
formatPrompt: (notes) => formatMarkdownReviewNotes(notes, renderedContent)
|
||||
}
|
||||
],
|
||||
[unsentMarkdownReviewNotes, unsentMarkdownReviewPrompt]
|
||||
[renderedContent, unsentMarkdownReviewNotes]
|
||||
)
|
||||
const canShowReviewTools = Boolean(
|
||||
markdownAnnotationsEnabled && sourceWorktree && sourceRelativePath !== null
|
||||
|
||||
@@ -65,7 +65,7 @@ export function useRichMarkdownReviewData({
|
||||
'All unsent notes'
|
||||
),
|
||||
notes: unsentNotes,
|
||||
prompt: formatMarkdownReviewNotes(unsentNotes, markdownReviewContent)
|
||||
formatPrompt: (notes) => formatMarkdownReviewNotes(notes, markdownReviewContent)
|
||||
}
|
||||
]
|
||||
}, [markdownReviewContent, markdownReviewNotes])
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
|
||||
/** How a watched message left its chat's outbox: sent on (accepted, or handed to the host or the
|
||||
* composer), or thrown away with the chat. */
|
||||
export type StructuredAgentSessionOutboxEntryRemoval = 'spent' | 'discarded'
|
||||
|
||||
type EntryWatch = {
|
||||
entry: StructuredAgentSessionOutboxEntry
|
||||
onGone: (removal: StructuredAgentSessionOutboxEntryRemoval) => void
|
||||
}
|
||||
|
||||
const watchesBySession = new Map<string, Set<EntryWatch>>()
|
||||
|
||||
// A user's Retry of a refused message gives it a new id; its text and queue time stay.
|
||||
function stillQueued(
|
||||
watched: StructuredAgentSessionOutboxEntry,
|
||||
entries: readonly StructuredAgentSessionOutboxEntry[]
|
||||
): boolean {
|
||||
const body = JSON.stringify(watched.body)
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.clientMessageId === watched.clientMessageId ||
|
||||
(entry.queuedAt === watched.queuedAt && JSON.stringify(entry.body) === body)
|
||||
)
|
||||
}
|
||||
|
||||
/** Calls `onGone` once, when `entry` leaves its session's outbox. */
|
||||
export function watchStructuredAgentSessionOutboxEntry(
|
||||
entry: StructuredAgentSessionOutboxEntry,
|
||||
onGone: (removal: StructuredAgentSessionOutboxEntryRemoval) => void
|
||||
): () => void {
|
||||
const watches = watchesBySession.get(entry.sessionId) ?? new Set()
|
||||
watchesBySession.set(entry.sessionId, watches)
|
||||
const watch: EntryWatch = { entry, onGone }
|
||||
watches.add(watch)
|
||||
return () => {
|
||||
watches.delete(watch)
|
||||
if (watches.size === 0 && watchesBySession.get(entry.sessionId) === watches) {
|
||||
watchesBySession.delete(entry.sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Run by the outbox's one write funnel after a change takes effect. */
|
||||
export function settleStructuredAgentSessionOutboxEntryWatches(
|
||||
sessionId: string,
|
||||
entries: readonly StructuredAgentSessionOutboxEntry[],
|
||||
removal: StructuredAgentSessionOutboxEntryRemoval
|
||||
): void {
|
||||
const watches = watchesBySession.get(sessionId)
|
||||
if (!watches) {
|
||||
return
|
||||
}
|
||||
for (const watch of watches) {
|
||||
if (!stillQueued(watch.entry, entries)) {
|
||||
watches.delete(watch)
|
||||
watch.onGone(removal)
|
||||
}
|
||||
}
|
||||
if (watches.size === 0) {
|
||||
watchesBySession.delete(sessionId)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { noteStructuredAgentSessionOutboxCommitted } from './structured-agent-session-entry-endings'
|
||||
import {
|
||||
settleStructuredAgentSessionOutboxEntryWatches,
|
||||
type StructuredAgentSessionOutboxEntryRemoval
|
||||
} from './structured-agent-session-outbox-entry-watch'
|
||||
|
||||
const OUTBOX_PREFIX = 'orca:desktopStructuredAgentSessionOutbox:v1:'
|
||||
|
||||
@@ -160,6 +164,15 @@ export function commitStructuredAgentSessionOutbox(
|
||||
sessionId: string,
|
||||
entries: StructuredAgentSessionOutboxEntry[],
|
||||
options: { onlyIfSaved?: boolean } = {}
|
||||
): boolean {
|
||||
return commitOutbox(sessionId, entries, options, 'spent')
|
||||
}
|
||||
|
||||
function commitOutbox(
|
||||
sessionId: string,
|
||||
entries: StructuredAgentSessionOutboxEntry[],
|
||||
options: { onlyIfSaved?: boolean },
|
||||
removal: StructuredAgentSessionOutboxEntryRemoval
|
||||
): boolean {
|
||||
const saved = writeOutbox(sessionId, entries)
|
||||
if (!saved && options.onlyIfSaved) {
|
||||
@@ -173,6 +186,7 @@ export function commitStructuredAgentSessionOutbox(
|
||||
}
|
||||
}
|
||||
noteStructuredAgentSessionOutboxCommitted(sessionId, entries)
|
||||
settleStructuredAgentSessionOutboxEntryWatches(sessionId, entries, removal)
|
||||
return saved
|
||||
}
|
||||
|
||||
@@ -211,5 +225,5 @@ export function enqueueStructuredAgentSessionLaunchPrompt(
|
||||
}
|
||||
|
||||
export function discardStructuredAgentSessionLaunchOutbox(sessionId: string): void {
|
||||
commitStructuredAgentSessionOutbox(sessionId, [])
|
||||
commitOutbox(sessionId, [], {}, 'discarded')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { StructuredLaunchState } from '@/lib/structured-agent-session-launch-registry'
|
||||
import {
|
||||
BLANK_STRUCTURED_LAUNCH_REQUEST,
|
||||
structuredLaunchRequest,
|
||||
type StructuredLaunchAttempt
|
||||
} from '@/lib/structured-agent-session-launch-request'
|
||||
|
||||
vi.mock('@/hooks/useDetectedAgents', () => ({
|
||||
useDetectedAgents: () => ({ detectedIds: ['claude', 'codex'] })
|
||||
}))
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null }))
|
||||
vi.mock('@/store', () => {
|
||||
const state = {
|
||||
settings: { defaultTuiAgent: 'codex', disabledTuiAgents: [] },
|
||||
worktreesByRepo: {},
|
||||
repos: [],
|
||||
openSettingsPage: vi.fn(),
|
||||
openSettingsTarget: vi.fn()
|
||||
}
|
||||
const useAppStore = Object.assign((selector: (s: typeof state) => unknown) => selector(state), {
|
||||
getState: () => state
|
||||
})
|
||||
return { useAppStore }
|
||||
})
|
||||
vi.mock('@/lib/agent-catalog', () => ({
|
||||
getAgentCatalog: () => [
|
||||
{ id: 'claude', label: 'Claude' },
|
||||
{ id: 'codex', label: 'Codex' }
|
||||
],
|
||||
AgentIcon: ({ agent }: { agent: string }) => <span>{agent}</span>
|
||||
}))
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
disabled,
|
||||
title,
|
||||
onSelect
|
||||
}: { children: ReactNode } & DivProps) => (
|
||||
<div aria-disabled={disabled ? 'true' : 'false'} title={title} onClick={onSelect}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuShortcut: ({ children }: { children: ReactNode }) => <span>{children}</span>
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
|
||||
fallback.replace('{{value0}}', values?.value0 ?? '')
|
||||
}))
|
||||
const launchMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: launchMock }))
|
||||
|
||||
type DivProps = { disabled?: boolean; title?: string; onSelect?: () => void }
|
||||
|
||||
import { QuickLaunchAgentMenuItems } from './QuickLaunchButton'
|
||||
import {
|
||||
resetStructuredAgentLaunchRegistryForTests,
|
||||
setStructuredLaunchState
|
||||
} from '@/lib/structured-agent-session-launch-registry'
|
||||
|
||||
const WORKTREE_ID = 'worktree-1'
|
||||
|
||||
function registerLaunch(
|
||||
agent: 'claude' | 'codex',
|
||||
outcome: 'pending' | 'failed',
|
||||
attempt: StructuredLaunchAttempt = {
|
||||
kind: 'first',
|
||||
request: BLANK_STRUCTURED_LAUNCH_REQUEST,
|
||||
stagedEntry: null
|
||||
}
|
||||
): void {
|
||||
const sessionId = `${agent}-session`
|
||||
setStructuredLaunchState({
|
||||
identity: `${agent}:${WORKTREE_ID}`,
|
||||
intent: {
|
||||
worktreeId: WORKTREE_ID,
|
||||
sessionId,
|
||||
executionHostId: 'local',
|
||||
target: { kind: 'local' },
|
||||
agent,
|
||||
params: {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: `operation-${sessionId}`,
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: `fingerprint-${sessionId}`
|
||||
},
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
agent
|
||||
}
|
||||
},
|
||||
promptDelivery: undefined,
|
||||
callers: {
|
||||
outcome,
|
||||
attempt,
|
||||
entries: new Set(),
|
||||
promptDeliveryResults: new Set(),
|
||||
onSettled: () => undefined
|
||||
},
|
||||
promise: new Promise(() => undefined),
|
||||
visibilityUnknown: false,
|
||||
cancelled: false,
|
||||
selection: { held: {} }
|
||||
} satisfies StructuredLaunchState)
|
||||
}
|
||||
|
||||
function agentRowDisabled(label: string): string | null | undefined {
|
||||
return document
|
||||
.querySelector(`[title="Launch ${label} in a new terminal"]`)
|
||||
?.getAttribute('aria-disabled')
|
||||
}
|
||||
|
||||
describe('QuickLaunchAgentMenuItems launch status', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
})
|
||||
afterEach(cleanup)
|
||||
|
||||
it('keeps an agent whose chat failed to start launchable while a starting one waits', () => {
|
||||
registerLaunch('claude', 'pending')
|
||||
registerLaunch('codex', 'failed')
|
||||
|
||||
render(
|
||||
<QuickLaunchAgentMenuItems
|
||||
worktreeId={WORKTREE_ID}
|
||||
groupId="group-1"
|
||||
onFocusTerminal={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(agentRowDisabled('Claude')).toBe('true')
|
||||
expect(agentRowDisabled('Codex')).toBe('false')
|
||||
})
|
||||
|
||||
// A pick then opens a new chat; only a new start's own create would be joined.
|
||||
it("keeps an agent launchable while a failed chat's Retry is in flight", () => {
|
||||
registerLaunch('claude', 'pending')
|
||||
registerLaunch('codex', 'pending', { kind: 'retry' })
|
||||
|
||||
render(
|
||||
<QuickLaunchAgentMenuItems
|
||||
worktreeId={WORKTREE_ID}
|
||||
groupId="group-1"
|
||||
onFocusTerminal={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(agentRowDisabled('Claude')).toBe('true')
|
||||
expect(agentRowDisabled('Codex')).toBe('false')
|
||||
})
|
||||
|
||||
// A pick joins only a start of the same request; any other opens its own chat.
|
||||
it('disables an agent only for the request its starting chat carries', () => {
|
||||
registerLaunch('codex', 'pending', {
|
||||
kind: 'first',
|
||||
request: structuredLaunchRequest({ prompt: 'review notes' }),
|
||||
stagedEntry: null
|
||||
})
|
||||
const menu = (prompt?: string) => (
|
||||
<QuickLaunchAgentMenuItems
|
||||
worktreeId={WORKTREE_ID}
|
||||
groupId="group-1"
|
||||
onFocusTerminal={vi.fn()}
|
||||
{...(prompt ? { prompt } : {})}
|
||||
/>
|
||||
)
|
||||
|
||||
render(menu())
|
||||
expect(agentRowDisabled('Codex')).toBe('false')
|
||||
cleanup()
|
||||
render(menu('other notes'))
|
||||
expect(agentRowDisabled('Codex')).toBe('false')
|
||||
cleanup()
|
||||
render(menu('review notes'))
|
||||
expect(agentRowDisabled('Codex')).toBe('true')
|
||||
})
|
||||
|
||||
// Why: the notes menu holds what it sent until this result, so a second send leaves them out.
|
||||
it("hands the launch's own delivery outcome to the notes menu", async () => {
|
||||
const delivery = Promise.resolve({ delivered: true, failureNotified: false })
|
||||
launchMock.mockReturnValue({
|
||||
surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'codex-session' },
|
||||
promptDeliveryResult: delivery
|
||||
})
|
||||
const onPromptHandedOff = vi.fn()
|
||||
|
||||
render(
|
||||
<QuickLaunchAgentMenuItems
|
||||
worktreeId={WORKTREE_ID}
|
||||
groupId="group-1"
|
||||
onFocusTerminal={vi.fn()}
|
||||
prompt="review notes"
|
||||
promptDelivery="submit-after-ready"
|
||||
onPromptHandedOff={onPromptHandedOff}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(document.querySelector('[title="Launch Codex in a new terminal"]')!)
|
||||
|
||||
expect(onPromptHandedOff).toHaveBeenCalledOnce()
|
||||
await expect(onPromptHandedOff.mock.calls[0][0]).resolves.toEqual({ delivered: true })
|
||||
})
|
||||
|
||||
it('starts no agent when the menu has nothing left to send', () => {
|
||||
launchMock.mockClear()
|
||||
render(
|
||||
<QuickLaunchAgentMenuItems
|
||||
worktreeId={WORKTREE_ID}
|
||||
groupId="group-1"
|
||||
onFocusTerminal={vi.fn()}
|
||||
prompt=""
|
||||
disabled
|
||||
/>
|
||||
)
|
||||
|
||||
expect(agentRowDisabled('Codex')).toBe('true')
|
||||
fireEvent.click(document.querySelector('[title="Launch Codex in a new terminal"]')!)
|
||||
expect(launchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
} from '../../../../shared/tui-agent-selection'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch'
|
||||
import { structuredLaunchRequest } from '@/lib/structured-agent-session-launch-request'
|
||||
import { newAgentPromptOutcome } from '@/lib/new-agent-prompt-outcome'
|
||||
|
||||
export type QuickLaunchAgentMenuItemsProps = {
|
||||
worktreeId: string
|
||||
@@ -37,6 +39,10 @@ export type QuickLaunchAgentMenuItemsProps = {
|
||||
launchSource?: LaunchSource
|
||||
/** Called after a prompt is queued into the agent, or immediately for argv prompt launches. */
|
||||
onPromptDelivered?: () => void
|
||||
/** Given the launch's own delivery result while the prompt is still on its way. */
|
||||
onPromptHandedOff?: (delivered: Promise<unknown>) => void
|
||||
/** Nothing to send: e.g. every note is already on its way, so no agent is started. */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function getCatalogEntry(agent: TuiAgent): { id: TuiAgent; label: string } | null {
|
||||
@@ -103,7 +109,9 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
prompt,
|
||||
promptDelivery,
|
||||
launchSource,
|
||||
onPromptDelivered
|
||||
onPromptDelivered,
|
||||
onPromptHandedOff,
|
||||
disabled = false
|
||||
}: QuickLaunchAgentMenuItemsProps): React.JSX.Element | null {
|
||||
// Why: resolving only the SSH connectionId here made paired-runtime
|
||||
// worktrees fall back to LOCAL detection, listing the client's agents
|
||||
@@ -119,10 +127,11 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent')
|
||||
// One hook per structured provider: the launch registry is keyed by agent, and hooks cannot run
|
||||
// inside the agent list's render loop.
|
||||
// inside the agent list's render loop. Only a start of this menu's own request is joined.
|
||||
const launchRequest = structuredLaunchRequest({ prompt, promptDelivery })
|
||||
const structuredLaunchStatusByAgent = {
|
||||
claude: useStructuredAgentLaunchStatus(worktreeId, 'claude'),
|
||||
codex: useStructuredAgentLaunchStatus(worktreeId, 'codex')
|
||||
claude: useStructuredAgentLaunchStatus(worktreeId, 'claude', launchRequest),
|
||||
codex: useStructuredAgentLaunchStatus(worktreeId, 'codex', launchRequest)
|
||||
}
|
||||
|
||||
const openAgentSettings = useCallback(() => {
|
||||
@@ -132,6 +141,9 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
|
||||
const runLaunch = useCallback(
|
||||
(agent: TuiAgent) => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
const entry = getCatalogEntry(agent)
|
||||
const label = entry?.label ?? agent
|
||||
const result = launchAgentInNewTab({
|
||||
@@ -153,6 +165,17 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
)
|
||||
return
|
||||
}
|
||||
if (onPromptHandedOff && result.promptDeliveryResult) {
|
||||
onPromptHandedOff(
|
||||
newAgentPromptOutcome({
|
||||
prompt: prompt ?? '',
|
||||
...(result.surface.kind === 'local-agent-session'
|
||||
? { sessionId: result.surface.sessionId }
|
||||
: {}),
|
||||
delivery: result.promptDeliveryResult
|
||||
})
|
||||
)
|
||||
}
|
||||
if (result.surface.kind !== 'local-terminal') {
|
||||
return
|
||||
}
|
||||
@@ -179,7 +202,17 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
toast.message(getLaunchWatchdogTimeoutMessage(label))
|
||||
})
|
||||
},
|
||||
[worktreeId, groupId, onFocusTerminal, prompt, promptDelivery, launchSource, onPromptDelivered]
|
||||
[
|
||||
worktreeId,
|
||||
groupId,
|
||||
onFocusTerminal,
|
||||
prompt,
|
||||
promptDelivery,
|
||||
launchSource,
|
||||
onPromptDelivered,
|
||||
onPromptHandedOff,
|
||||
disabled
|
||||
]
|
||||
)
|
||||
|
||||
const enabledDetectedIds = detectedIds ? filterEnabledTuiAgents(detectedIds, disabledAgents) : []
|
||||
@@ -210,7 +243,7 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={agent}
|
||||
disabled={isStructuredLaunchPending}
|
||||
disabled={disabled || isStructuredLaunchPending}
|
||||
onSelect={() => runLaunch(agent)}
|
||||
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
|
||||
title={translate(
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from './tab-create-entry-copy'
|
||||
import { EMPTY_AGENT_OPTIONS, EMPTY_MENU_OPTIONS } from './tab-create-entry-empty-options'
|
||||
import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch'
|
||||
import { BLANK_STRUCTURED_LAUNCH_REQUEST } from '@/lib/structured-agent-session-launch-request'
|
||||
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import type { TabEntryActionClassification } from './tab-create-entry-classifier'
|
||||
@@ -65,8 +66,8 @@ function TabBarCreateEntrySession({
|
||||
// One hook per structured provider: the launch registry is keyed by agent, and hooks cannot run
|
||||
// inside the option render loop.
|
||||
const structuredLaunchStatusByAgent = {
|
||||
claude: useStructuredAgentLaunchStatus(worktreeId, 'claude'),
|
||||
codex: useStructuredAgentLaunchStatus(worktreeId, 'codex')
|
||||
claude: useStructuredAgentLaunchStatus(worktreeId, 'claude', BLANK_STRUCTURED_LAUNCH_REQUEST),
|
||||
codex: useStructuredAgentLaunchStatus(worktreeId, 'codex', BLANK_STRUCTURED_LAUNCH_REQUEST)
|
||||
}
|
||||
const isStructuredLaunchPending = (agent: TuiAgent): boolean =>
|
||||
isAgentSessionHandleProvider(agent) && structuredLaunchStatusByAgent[agent] === 'pending'
|
||||
|
||||
@@ -471,7 +471,7 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
await vi.waitFor(() => expect(mockToastError).not.toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('does not create a sibling when post-create visibility proof is unknown', async () => {
|
||||
it('re-checks a chat whose visibility proof is unknown through its own Retry, with its own intent', async () => {
|
||||
store.unifiedTabsByWorktree = {}
|
||||
const firstIntent = structuredLaunchIntent('wt-1', 'codex-session-1')
|
||||
const secondIntent = structuredLaunchIntent('wt-1', 'codex-session-2')
|
||||
@@ -519,7 +519,9 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
)
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
|
||||
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
|
||||
// A new launch would open a new chat; the unconfirmed one is re-checked by its own Retry.
|
||||
const { retryStructuredAgentSessionLaunch } = await import('./structured-agent-session-launch')
|
||||
expect(retryStructuredAgentSessionLaunch('wt-1', firstIntent.sessionId)).toBe(true)
|
||||
await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(3))
|
||||
|
||||
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
|
||||
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callStructuredAgentSession: vi.fn(),
|
||||
createIntent: vi.fn(),
|
||||
launch: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } }))
|
||||
|
||||
vi.mock('@/lib/launch-structured-agent-session', () => {
|
||||
class StructuredAgentSessionCreateRefusalError extends Error {}
|
||||
return {
|
||||
createStructuredAgentSessionLaunchIntent: mocks.createIntent,
|
||||
retryStructuredAgentSessionLaunchIntent: (intent: unknown) => intent,
|
||||
restoreStructuredAgentSessionLaunchIntent: vi.fn(),
|
||||
abandonStructuredAgentSessionLaunchIntent: vi.fn(),
|
||||
launchStructuredAgentSession: mocks.launch,
|
||||
StructuredAgentSessionCreateRefusalError
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
|
||||
refreshLocalStructuredSessionTabs: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.callStructuredAgentSession
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
unifiedTabsByWorktree: {},
|
||||
seedNativeChatLaunchDraft: vi.fn(),
|
||||
clearNativeChatLaunchDraft: vi.fn()
|
||||
}),
|
||||
subscribe: () => () => undefined
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
|
||||
vi.mock('@/lib/agent-catalog', () => ({
|
||||
getAgentLabel: () => 'Codex',
|
||||
getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }]
|
||||
}))
|
||||
|
||||
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
|
||||
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
|
||||
import {
|
||||
mutateStructuredAgentSessionLaunchPrompt,
|
||||
readOutbox
|
||||
} from '@/components/native-chat/structured-agent-session-outbox-storage'
|
||||
import {
|
||||
cancelStructuredAgentLaunch,
|
||||
getStructuredAgentSessionLaunchLifecycle,
|
||||
retryStructuredAgentSessionLaunch,
|
||||
startStructuredAgentLaunch
|
||||
} from './structured-agent-session-launch'
|
||||
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
|
||||
import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry'
|
||||
import {
|
||||
holdNotesForSend,
|
||||
isNoteInFlight,
|
||||
resetNotesInFlightForTests
|
||||
} from './notes-send-in-flight'
|
||||
import { newAgentPromptOutcome } from './new-agent-prompt-outcome'
|
||||
|
||||
const WORKTREE_ID = 'wt-notes-new-agent'
|
||||
const NOTES = 'review notes'
|
||||
|
||||
function launchIntent(sessionId: string): StructuredAgentSessionLaunchIntent {
|
||||
return {
|
||||
worktreeId: WORKTREE_ID,
|
||||
sessionId,
|
||||
executionHostId: 'local',
|
||||
target: { kind: 'local' },
|
||||
agent: 'codex',
|
||||
params: {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: `operation-${sessionId}`,
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: `fingerprint-${sessionId}`
|
||||
},
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
agent: 'codex'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function published(sessionId: string): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: [
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: 'tab-1',
|
||||
title: 'Codex',
|
||||
sessionId,
|
||||
agent: 'codex',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
const chat = launchIntent('session-notes')
|
||||
|
||||
/** What the notes menu does with a "New agent" pick: the launch, then the hold on its outcome. */
|
||||
function sendNotesToNewAgent(options: { paired?: boolean } = {}) {
|
||||
const onDelivered = vi.fn()
|
||||
const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: NOTES,
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
holdNotesForSend(
|
||||
['note-a'],
|
||||
newAgentPromptOutcome({
|
||||
prompt: NOTES,
|
||||
...(options.paired ? {} : { sessionId: launch.sessionId }),
|
||||
delivery: launch.promptDeliveryResult!
|
||||
}),
|
||||
onDelivered
|
||||
)
|
||||
return { launch, onDelivered }
|
||||
}
|
||||
|
||||
/** A start the host refused outright: the chat shows it failed, with Retry. */
|
||||
async function failTheStart(): Promise<void> {
|
||||
await settle()
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, chat.sessionId)).toBe('failed')
|
||||
}
|
||||
|
||||
describe('notes sent to a new agent', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
localStorage.clear()
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
resetNotesInFlightForTests()
|
||||
mocks.createIntent.mockReturnValue(chat)
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([published(chat.sessionId)])
|
||||
mocks.callStructuredAgentSession.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { submission: { dispatchState: 'accepted' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the shelf once the new chat delivers them', async () => {
|
||||
mocks.launch.mockResolvedValue({ sessionId: chat.sessionId, fence: 1 })
|
||||
const { onDelivered } = sendNotesToNewAgent()
|
||||
expect(isNoteInFlight('note-a')).toBe(true)
|
||||
|
||||
await settle()
|
||||
|
||||
expect(onDelivered).toHaveBeenCalledOnce()
|
||||
expect(isNoteInFlight('note-a')).toBe(false)
|
||||
})
|
||||
|
||||
it('stay held, not resendable, while a failed chat keeps them for its Retry', async () => {
|
||||
mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported'))
|
||||
const { onDelivered } = sendNotesToNewAgent()
|
||||
await failTheStart()
|
||||
|
||||
expect(readOutbox(chat.sessionId)).toHaveLength(1)
|
||||
expect(isNoteInFlight('note-a')).toBe(true)
|
||||
expect(onDelivered).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("leave the shelf when that chat's Retry delivers them", async () => {
|
||||
mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
|
||||
const { onDelivered } = sendNotesToNewAgent()
|
||||
await failTheStart()
|
||||
mocks.launch.mockResolvedValue({ sessionId: chat.sessionId, fence: 1 })
|
||||
|
||||
expect(retryStructuredAgentSessionLaunch(WORKTREE_ID, chat.sessionId)).toBe(true)
|
||||
await settle()
|
||||
// The open chat's own send accepts the staged prompt, as every dispatch does.
|
||||
const [entry] = readOutbox(chat.sessionId)
|
||||
mutateStructuredAgentSessionLaunchPrompt(chat.sessionId, entry.clientMessageId, () => null)
|
||||
await settle()
|
||||
|
||||
expect(onDelivered).toHaveBeenCalledOnce()
|
||||
expect(isNoteInFlight('note-a')).toBe(false)
|
||||
})
|
||||
|
||||
it('come back to the shelf when the failed chat is closed', async () => {
|
||||
mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported'))
|
||||
const { onDelivered } = sendNotesToNewAgent()
|
||||
await failTheStart()
|
||||
|
||||
cancelStructuredAgentLaunch(WORKTREE_ID, chat.sessionId)
|
||||
await settle()
|
||||
|
||||
expect(isNoteInFlight('note-a')).toBe(false)
|
||||
expect(onDelivered).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('come back when a chat still starting is closed, without waiting on its create', async () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
const { onDelivered } = sendNotesToNewAgent()
|
||||
|
||||
cancelStructuredAgentLaunch(WORKTREE_ID, chat.sessionId)
|
||||
await settle()
|
||||
|
||||
expect(isNoteInFlight('note-a')).toBe(false)
|
||||
expect(onDelivered).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("stay held by a paired server's failed chat, found once its start settles", async () => {
|
||||
mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported'))
|
||||
sendNotesToNewAgent({ paired: true })
|
||||
await failTheStart()
|
||||
|
||||
expect(isNoteInFlight('note-a')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox'
|
||||
import { getStructuredAgentSessionOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage'
|
||||
import { watchStructuredAgentSessionOutboxEntry } from '@/components/native-chat/structured-agent-session-outbox-entry-watch'
|
||||
import { structuredLaunchStates } from './structured-agent-session-launch-registry'
|
||||
|
||||
export type NewAgentPromptOutcome = { delivered: boolean }
|
||||
|
||||
function stagedLaunchPrompt(
|
||||
sessionId: string,
|
||||
text: string
|
||||
): StructuredAgentSessionOutboxEntry | undefined {
|
||||
return getStructuredAgentSessionOutbox(sessionId).findLast(
|
||||
(entry) =>
|
||||
entry.source === 'launch' &&
|
||||
entry.body.blocks.some((block) => block.type === 'text' && block.text === text)
|
||||
)
|
||||
}
|
||||
|
||||
/** A failed or unconfirmed chat keeps its prompt staged for its own Retry or re-check. */
|
||||
function stagedInAnyLaunch(text: string): StructuredAgentSessionOutboxEntry | undefined {
|
||||
let found: StructuredAgentSessionOutboxEntry | undefined
|
||||
for (const state of structuredLaunchStates()) {
|
||||
found = stagedLaunchPrompt(state.intent.sessionId, text) ?? found
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function outcomeOf(entry: StructuredAgentSessionOutboxEntry): Promise<NewAgentPromptOutcome> {
|
||||
return new Promise((resolve) => {
|
||||
watchStructuredAgentSessionOutboxEntry(entry, (removal) =>
|
||||
resolve({ delivered: removal === 'spent' })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles once a new agent's prompt is sent on (delivered) or thrown away with its chat. A chat's
|
||||
* staged prompt is the authority, so a Retry or re-check that sends it later still counts, and a
|
||||
* failed start does not hand the text back while that chat still holds it.
|
||||
*/
|
||||
export function newAgentPromptOutcome(args: {
|
||||
prompt: string
|
||||
sessionId?: string
|
||||
delivery: Promise<{ delivered: boolean }>
|
||||
}): Promise<NewAgentPromptOutcome> {
|
||||
const text = args.prompt.trim()
|
||||
const staged = args.sessionId ? stagedLaunchPrompt(args.sessionId, text) : undefined
|
||||
if (staged) {
|
||||
return outcomeOf(staged)
|
||||
}
|
||||
// A paired server's chat exists only once it is admitted, so look again after the start.
|
||||
const afterStart = (
|
||||
delivered: boolean
|
||||
): Promise<NewAgentPromptOutcome> | NewAgentPromptOutcome => {
|
||||
const kept = delivered ? undefined : stagedInAnyLaunch(text)
|
||||
return kept ? outcomeOf(kept) : { delivered }
|
||||
}
|
||||
return args.delivery.then(
|
||||
(result) => afterStart(result.delivered),
|
||||
() => afterStart(false)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { DiffCommentDeliverySnapshot } from '@/store/slices/diffComments'
|
||||
|
||||
// Why: notes handed to a send leave the next send at once and come back only if that delivery
|
||||
// fails, as a submitted composer clears and restores on error. Delivered notes are still removed
|
||||
// by their owner; a hold lives only until its delivery settles.
|
||||
const holds = new Map<unknown, number>()
|
||||
const listeners = new Set<() => void>()
|
||||
let version = 0
|
||||
|
||||
function changed(): void {
|
||||
version += 1
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
function reportsDelivered(result: unknown): boolean {
|
||||
return typeof result === 'object' && result !== null && 'delivered' in result
|
||||
? result.delivered === true
|
||||
: false
|
||||
}
|
||||
|
||||
/** Takes `keys` out of the next send until `delivered` settles, whatever its result. A result that
|
||||
* reports delivery also runs `onDelivered`, for a send whose own callback can no longer fire
|
||||
* (a Retry of a failed new chat). */
|
||||
export function holdNotesForSend(
|
||||
keys: readonly unknown[],
|
||||
delivered: Promise<unknown>,
|
||||
onDelivered?: () => void
|
||||
): void {
|
||||
if (keys.length === 0) {
|
||||
return
|
||||
}
|
||||
// Registered before the release, so delivered notes are gone before they could show again.
|
||||
void delivered.then(
|
||||
(result) => {
|
||||
if (reportsDelivered(result)) {
|
||||
onDelivered?.()
|
||||
}
|
||||
},
|
||||
() => undefined
|
||||
)
|
||||
for (const key of keys) {
|
||||
holds.set(key, (holds.get(key) ?? 0) + 1)
|
||||
}
|
||||
changed()
|
||||
const release = (): void => {
|
||||
for (const key of keys) {
|
||||
const count = (holds.get(key) ?? 1) - 1
|
||||
if (count > 0) {
|
||||
holds.set(key, count)
|
||||
} else {
|
||||
holds.delete(key)
|
||||
}
|
||||
}
|
||||
changed()
|
||||
}
|
||||
void delivered.then(release, release)
|
||||
}
|
||||
|
||||
export function isNoteInFlight(key: unknown): boolean {
|
||||
return holds.has(key)
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
function getVersion(): number {
|
||||
return version
|
||||
}
|
||||
|
||||
/** Changes whenever a hold starts or ends, for memos that filter by `isNoteInFlight`. */
|
||||
export function useNotesInFlightVersion(): number {
|
||||
return useSyncExternalStore(subscribe, getVersion, getVersion)
|
||||
}
|
||||
|
||||
/** A note's identity for delivery: an edit makes it a new pending note, as for its removal. */
|
||||
export function diffCommentSendKey(note: DiffCommentDeliverySnapshot): string {
|
||||
return JSON.stringify([
|
||||
note.id,
|
||||
note.body,
|
||||
note.filePath,
|
||||
note.lineNumber,
|
||||
note.startLine ?? null,
|
||||
note.selectedText ?? null,
|
||||
note.source ?? null
|
||||
])
|
||||
}
|
||||
|
||||
export function resetNotesInFlightForTests(): void {
|
||||
holds.clear()
|
||||
changed()
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
|
||||
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
abandonIntent: vi.fn(),
|
||||
callStructuredAgentSession: vi.fn(),
|
||||
createIntent: vi.fn(),
|
||||
retryIntent: vi.fn(),
|
||||
restoreIntent: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
seedDraft: vi.fn(),
|
||||
clearDraft: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } }))
|
||||
|
||||
vi.mock('@/lib/launch-structured-agent-session', () => {
|
||||
class StructuredAgentSessionCreateRefusalError extends Error {}
|
||||
return {
|
||||
createStructuredAgentSessionLaunchIntent: mocks.createIntent,
|
||||
retryStructuredAgentSessionLaunchIntent: mocks.retryIntent,
|
||||
restoreStructuredAgentSessionLaunchIntent: mocks.restoreIntent,
|
||||
abandonStructuredAgentSessionLaunchIntent: mocks.abandonIntent,
|
||||
launchStructuredAgentSession: mocks.launch,
|
||||
StructuredAgentSessionCreateRefusalError
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
|
||||
refreshLocalStructuredSessionTabs: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.callStructuredAgentSession
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
unifiedTabsByWorktree: {},
|
||||
seedNativeChatLaunchDraft: mocks.seedDraft,
|
||||
clearNativeChatLaunchDraft: mocks.clearDraft
|
||||
}),
|
||||
subscribe: () => () => undefined
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-catalog', () => ({
|
||||
getAgentLabel: () => 'Codex',
|
||||
getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }]
|
||||
}))
|
||||
|
||||
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
|
||||
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
|
||||
import {
|
||||
cancelStructuredAgentLaunch,
|
||||
getStructuredAgentLaunchStatus,
|
||||
getStructuredAgentSessionLaunchLifecycle,
|
||||
retryStructuredAgentSessionLaunch,
|
||||
startStructuredAgentLaunch
|
||||
} from './structured-agent-session-launch'
|
||||
import { readOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage'
|
||||
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
|
||||
import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry'
|
||||
|
||||
type Receipt = { sessionId: string; fence: number }
|
||||
|
||||
const WORKTREE_ID = 'wt-after-failure'
|
||||
|
||||
function launchIntent(sessionId: string): StructuredAgentSessionLaunchIntent {
|
||||
return {
|
||||
worktreeId: WORKTREE_ID,
|
||||
sessionId,
|
||||
executionHostId: 'local',
|
||||
target: { kind: 'local' },
|
||||
agent: 'codex',
|
||||
params: {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: `operation-${sessionId}`,
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: `fingerprint-${sessionId}`
|
||||
},
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
agent: 'codex'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function publishedSnapshot(...sessionIds: string[]): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: sessionIds.map((sessionId) => ({
|
||||
type: 'agent-session' as const,
|
||||
id: `tab-${sessionId}`,
|
||||
title: 'Codex',
|
||||
sessionId,
|
||||
agent: 'codex' as const,
|
||||
isActive: false
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async function flushLaunchSettlement(): Promise<void> {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
const failed = launchIntent('session-failed')
|
||||
const fresh = launchIntent('session-new')
|
||||
|
||||
async function refuseFirstLaunch(): Promise<void> {
|
||||
mocks.createIntent.mockReturnValueOnce(failed).mockReturnValueOnce(fresh)
|
||||
mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
|
||||
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'first task' })
|
||||
await flushLaunchSettlement()
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, failed.sessionId)).toBe('failed')
|
||||
}
|
||||
|
||||
describe('a new launch after a failed one', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
localStorage.clear()
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
mocks.retryIntent.mockImplementation((intent: StructuredAgentSessionLaunchIntent) => ({
|
||||
...intent,
|
||||
params: {
|
||||
...intent.params,
|
||||
envelope: { ...intent.params.envelope, clientOperationId: 'retried-operation' }
|
||||
}
|
||||
}))
|
||||
mocks.restoreIntent.mockImplementation((args: { sessionId: string }) =>
|
||||
launchIntent(args.sessionId)
|
||||
)
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
|
||||
publishedSnapshot(failed.sessionId, fresh.sessionId)
|
||||
])
|
||||
mocks.callStructuredAgentSession.mockResolvedValue({
|
||||
ok: true,
|
||||
page: { fence: 1 },
|
||||
value: { submission: { dispatchState: 'accepted' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a new chat that sends its own prompt instead of restarting the failed one', async () => {
|
||||
await refuseFirstLaunch()
|
||||
// Why: the + menu and new-tab search disable an agent only while its launch reads pending.
|
||||
expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('idle')
|
||||
|
||||
mocks.launch.mockResolvedValueOnce({ sessionId: fresh.sessionId, fence: 1 })
|
||||
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: 'review notes',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
|
||||
expect(notes.sessionId).toBe(fresh.sessionId)
|
||||
expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('pending')
|
||||
await expect(notes.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
expect(mocks.retryIntent).not.toHaveBeenCalled()
|
||||
expect(mocks.callStructuredAgentSession).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'agentSession.send',
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({ sessionId: fresh.sessionId }),
|
||||
body: expect.objectContaining({ blocks: [{ type: 'text', text: 'review notes' }] })
|
||||
})
|
||||
)
|
||||
// The failed chat keeps its own preserved prompt for its own Retry.
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, failed.sessionId)).toBe('failed')
|
||||
expect(readOutbox(failed.sessionId)).toEqual([
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({ blocks: [{ type: 'text', text: 'first task' }] })
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('retries the failed chat beside an in-flight new launch without taking it over', async () => {
|
||||
await refuseFirstLaunch()
|
||||
let resolveFresh!: (receipt: Receipt) => void
|
||||
let rejectRetry!: (error: unknown) => void
|
||||
mocks.launch
|
||||
.mockImplementationOnce(() => new Promise<Receipt>((resolve) => (resolveFresh = resolve)))
|
||||
.mockImplementationOnce(
|
||||
() => new Promise<Receipt>((_resolve, reject) => (rejectRetry = reject))
|
||||
)
|
||||
const next = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
|
||||
|
||||
expect(retryStructuredAgentSessionLaunch(WORKTREE_ID, failed.sessionId)).toBe(true)
|
||||
expect(mocks.launch.mock.calls[2]?.[0]).toMatchObject({ sessionId: failed.sessionId })
|
||||
// A third start joins the new launch, not the retried chat.
|
||||
expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex').sessionId).toBe(fresh.sessionId)
|
||||
expect(mocks.createIntent).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Closing the retried chat leaves the new launch registered and starting.
|
||||
rejectRetry(new StructuredAgentSessionCreateRefusalError('unsupported'))
|
||||
await flushLaunchSettlement()
|
||||
expect(cancelStructuredAgentLaunch(WORKTREE_ID, failed.sessionId)).toBe(true)
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, fresh.sessionId)).toBe('pending')
|
||||
expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('pending')
|
||||
|
||||
resolveFresh({ sessionId: fresh.sessionId, fence: 1 })
|
||||
await expect(next.launchResult).resolves.toEqual({ sessionId: fresh.sessionId, fence: 1 })
|
||||
expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('idle')
|
||||
})
|
||||
|
||||
it('restores a failed chat after reload without unregistering a newer launch', async () => {
|
||||
await refuseFirstLaunch()
|
||||
// Reload: the registry is memory; the failed record is what survives.
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
let resolveFresh!: (receipt: Receipt) => void
|
||||
mocks.launch
|
||||
.mockImplementationOnce(() => new Promise<Receipt>((resolve) => (resolveFresh = resolve)))
|
||||
.mockResolvedValueOnce({ sessionId: failed.sessionId, fence: 1 })
|
||||
const next = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
|
||||
|
||||
expect(retryStructuredAgentSessionLaunch(WORKTREE_ID, failed.sessionId)).toBe(true)
|
||||
await flushLaunchSettlement()
|
||||
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, failed.sessionId)).toBeNull()
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, fresh.sessionId)).toBe('pending')
|
||||
expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('pending')
|
||||
resolveFresh({ sessionId: fresh.sessionId, fence: 1 })
|
||||
await expect(next.launchResult).resolves.toEqual({ sessionId: fresh.sessionId, fence: 1 })
|
||||
})
|
||||
|
||||
it('reads a failed resume as not starting, so resuming again opens a new chat', async () => {
|
||||
const resumeFrom = { providerSessionId: 'provider-1' }
|
||||
mocks.createIntent.mockReturnValueOnce(failed).mockReturnValueOnce(fresh)
|
||||
mocks.launch
|
||||
.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
|
||||
.mockResolvedValueOnce({ sessionId: fresh.sessionId, fence: 1 })
|
||||
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { resumeFrom })
|
||||
await flushLaunchSettlement()
|
||||
expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('idle')
|
||||
|
||||
expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex', { resumeFrom }).sessionId).toBe(
|
||||
fresh.sessionId
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structur
|
||||
import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { StructuredLaunchAttempt } from './structured-agent-session-launch-request'
|
||||
|
||||
export type StructuredAgentLaunchOptions = {
|
||||
prompt?: string
|
||||
@@ -24,14 +25,18 @@ export type StructuredLaunchCaller = {
|
||||
|
||||
export type StructuredLaunchCallerGroup = {
|
||||
outcome: 'pending' | 'published' | 'failed' | 'unknown' | 'cancelled'
|
||||
attempt: StructuredLaunchAttempt
|
||||
entries: Set<StructuredLaunchCaller>
|
||||
promptDeliveryResults: Set<Promise<StructuredPromptDeliveryResult>>
|
||||
onSettled: () => void
|
||||
}
|
||||
|
||||
export function createStructuredLaunchCallerGroup(): StructuredLaunchCallerGroup {
|
||||
export function createStructuredLaunchCallerGroup(
|
||||
attempt: StructuredLaunchAttempt
|
||||
): StructuredLaunchCallerGroup {
|
||||
return {
|
||||
outcome: 'pending',
|
||||
attempt,
|
||||
entries: new Set(),
|
||||
promptDeliveryResults: new Set(),
|
||||
onSettled: () => {}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import { suppressCancelledStructuredSessionTabs } from '@/runtime/structured-agent-session-tab-retirement'
|
||||
import type { StructuredLaunchState } from './structured-agent-session-launch-registry'
|
||||
import { BLANK_STRUCTURED_LAUNCH_REQUEST } from './structured-agent-session-launch-request'
|
||||
import {
|
||||
hasStructuredAgentSessionLaunchCancellationTombstone,
|
||||
markStructuredAgentSessionLaunchCancelled,
|
||||
@@ -82,6 +83,7 @@ describe('structured launch cancellation retirement', () => {
|
||||
promptDelivery: 'auto-submit',
|
||||
callers: {
|
||||
outcome: 'pending',
|
||||
attempt: { kind: 'first', request: BLANK_STRUCTURED_LAUNCH_REQUEST, stagedEntry: null },
|
||||
entries: new Set(),
|
||||
promptDeliveryResults: new Set(),
|
||||
onSettled: () => undefined
|
||||
@@ -161,6 +163,7 @@ describe('structured launch cancellation retirement', () => {
|
||||
promptDelivery: 'auto-submit',
|
||||
callers: {
|
||||
outcome: 'pending',
|
||||
attempt: { kind: 'first', request: BLANK_STRUCTURED_LAUNCH_REQUEST, stagedEntry: null },
|
||||
entries: new Set(),
|
||||
promptDeliveryResults: new Set(),
|
||||
onSettled: () => undefined
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('a close that races a structured launch', () => {
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards every coalesced prompt when a close cancels the launch', async () => {
|
||||
it("discards a repeated request's one staged prompt when a close cancels the launch", async () => {
|
||||
const worktreeId = 'wt-close-coalesced-prompts'
|
||||
const intent = launchIntent(worktreeId)
|
||||
let resolveRefresh!: (snapshots: RuntimeMobileSessionTabsResult[]) => void
|
||||
@@ -188,9 +188,9 @@ describe('a close that races a structured launch', () => {
|
||||
)
|
||||
|
||||
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
|
||||
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
|
||||
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
|
||||
await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledOnce())
|
||||
expect(readOutbox(intent.sessionId)).toHaveLength(2)
|
||||
expect(readOutbox(intent.sessionId)).toHaveLength(1)
|
||||
|
||||
expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(true)
|
||||
expect(readOutbox(intent.sessionId)).toEqual([])
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
|
||||
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
abandonIntent: vi.fn(),
|
||||
callStructuredAgentSession: vi.fn(),
|
||||
createIntent: vi.fn(),
|
||||
retryIntent: vi.fn(),
|
||||
restoreIntent: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
seedDraft: vi.fn(),
|
||||
clearDraft: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } }))
|
||||
|
||||
vi.mock('@/lib/launch-structured-agent-session', () => {
|
||||
class StructuredAgentSessionCreateRefusalError extends Error {}
|
||||
return {
|
||||
createStructuredAgentSessionLaunchIntent: mocks.createIntent,
|
||||
retryStructuredAgentSessionLaunchIntent: mocks.retryIntent,
|
||||
restoreStructuredAgentSessionLaunchIntent: mocks.restoreIntent,
|
||||
abandonStructuredAgentSessionLaunchIntent: mocks.abandonIntent,
|
||||
launchStructuredAgentSession: mocks.launch,
|
||||
StructuredAgentSessionCreateRefusalError
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
|
||||
refreshLocalStructuredSessionTabs: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.callStructuredAgentSession
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
unifiedTabsByWorktree: {},
|
||||
seedNativeChatLaunchDraft: mocks.seedDraft,
|
||||
clearNativeChatLaunchDraft: mocks.clearDraft
|
||||
}),
|
||||
subscribe: () => () => undefined
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-catalog', () => ({
|
||||
getAgentLabel: () => 'Codex',
|
||||
getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }]
|
||||
}))
|
||||
|
||||
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
|
||||
import {
|
||||
appendStructuredAgentSessionOutboxMessage,
|
||||
readOutbox
|
||||
} from '@/components/native-chat/structured-agent-session-outbox-storage'
|
||||
import {
|
||||
clearNativeChatDraftCacheForTests,
|
||||
readNativeChatDraftCache,
|
||||
writeNativeChatDraftCache
|
||||
} from '@/components/native-chat/native-chat-draft-cache'
|
||||
import {
|
||||
structuredAgentSessionPaneKey,
|
||||
structuredAgentSessionTabId
|
||||
} from '../../../shared/structured-agent-session-projection'
|
||||
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
|
||||
import {
|
||||
getStructuredAgentLaunchStatus,
|
||||
getStructuredAgentSessionLaunchLifecycle,
|
||||
startStructuredAgentLaunch
|
||||
} from './structured-agent-session-launch'
|
||||
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
|
||||
import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry'
|
||||
import { structuredLaunchRequest } from './structured-agent-session-launch-request'
|
||||
|
||||
const WORKTREE_ID = 'wt-different-request'
|
||||
|
||||
function launchIntent(sessionId: string): StructuredAgentSessionLaunchIntent {
|
||||
return {
|
||||
worktreeId: WORKTREE_ID,
|
||||
sessionId,
|
||||
executionHostId: 'local',
|
||||
target: { kind: 'local' },
|
||||
agent: 'codex',
|
||||
params: {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: `operation-${sessionId}`,
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: `fingerprint-${sessionId}`
|
||||
},
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
agent: 'codex'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function publishedSnapshot(...sessionIds: string[]): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: sessionIds.map((sessionId) => ({
|
||||
type: 'agent-session',
|
||||
id: `tab-${sessionId}`,
|
||||
title: 'Codex',
|
||||
sessionId,
|
||||
agent: 'codex',
|
||||
isActive: false
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const first = launchIntent('session-first')
|
||||
const second = launchIntent('session-second')
|
||||
|
||||
/** Every `agentSession.send` as [session, text]. */
|
||||
function sends(): [string, string][] {
|
||||
return mocks.callStructuredAgentSession.mock.calls
|
||||
.filter((call) => call[1] === 'agentSession.send')
|
||||
.map((call) => [call[2].envelope.sessionId, call[2].body.blocks[0].text])
|
||||
}
|
||||
|
||||
describe('a different new request while the first chat is still starting', () => {
|
||||
let resolveFirstLaunch!: (receipt: { sessionId: string; fence: number }) => void
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
localStorage.clear()
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
mocks.createIntent.mockReturnValueOnce(first).mockReturnValueOnce(second)
|
||||
mocks.launch.mockImplementation((intent: StructuredAgentSessionLaunchIntent) =>
|
||||
intent.sessionId === first.sessionId
|
||||
? new Promise((resolve) => (resolveFirstLaunch = resolve))
|
||||
: Promise.resolve({ sessionId: intent.sessionId, fence: 1 })
|
||||
)
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
|
||||
publishedSnapshot(first.sessionId, second.sessionId)
|
||||
])
|
||||
mocks.callStructuredAgentSession.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { submission: { dispatchState: 'accepted' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a new chat with its own text while the first create is in flight', async () => {
|
||||
const checkA = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: 'Fix check A',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
const checkB = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: 'Fix check B',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
|
||||
expect(checkB.sessionId).toBe(second.sessionId)
|
||||
await expect(checkB.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 })
|
||||
await expect(checkA.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
expect(sends()).toEqual([
|
||||
[second.sessionId, 'Fix check B'],
|
||||
[first.sessionId, 'Fix check A']
|
||||
])
|
||||
})
|
||||
|
||||
it('opens a new chat with its own text while the first chat is still sending its text', async () => {
|
||||
let resolveFirstSend!: (result: unknown) => void
|
||||
mocks.callStructuredAgentSession.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveFirstSend = resolve))
|
||||
)
|
||||
const checkA = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: 'Fix check A',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 })
|
||||
await vi.waitFor(() => expect(sends()).toEqual([[first.sessionId, 'Fix check A']]))
|
||||
|
||||
const checkB = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: 'Fix check B',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
|
||||
expect(checkB.sessionId).toBe(second.sessionId)
|
||||
await expect(checkB.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
resolveFirstSend({ ok: true, value: { submission: { dispatchState: 'accepted' } } })
|
||||
await expect(checkA.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
expect(sends()).toEqual([
|
||||
[first.sessionId, 'Fix check A'],
|
||||
[second.sessionId, 'Fix check B']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the same request repeated while the first chat is still starting', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
localStorage.clear()
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
mocks.createIntent.mockReturnValueOnce(first).mockReturnValueOnce(second)
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
|
||||
publishedSnapshot(first.sessionId)
|
||||
])
|
||||
mocks.callStructuredAgentSession.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { submission: { dispatchState: 'accepted' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('makes one chat from a double click with no text', () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
|
||||
const pick = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
|
||||
const again = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
|
||||
expect(again.sessionId).toBe(pick.sessionId)
|
||||
expect(mocks.createIntent).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('makes one chat and sends its text once while the create is in flight', async () => {
|
||||
let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
|
||||
mocks.launch.mockImplementation(() => new Promise((resolve) => (resolveLaunch = resolve)))
|
||||
const onFirstDelivered = vi.fn()
|
||||
const onRepeatDelivered = vi.fn()
|
||||
|
||||
const click = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: 'Fix check A',
|
||||
promptDelivery: 'submit-after-ready',
|
||||
onPromptDelivered: onFirstDelivered
|
||||
})
|
||||
const repeat = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: 'Fix check A',
|
||||
promptDelivery: 'submit-after-ready',
|
||||
onPromptDelivered: onRepeatDelivered
|
||||
})
|
||||
resolveLaunch({ sessionId: first.sessionId, fence: 1 })
|
||||
|
||||
expect(repeat.sessionId).toBe(click.sessionId)
|
||||
for (const caller of [click, repeat]) {
|
||||
await expect(caller.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
}
|
||||
expect(sends()).toEqual([[first.sessionId, 'Fix check A']])
|
||||
expect(onFirstDelivered).toHaveBeenCalledOnce()
|
||||
expect(onRepeatDelivered).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('seeds a repeated draft once', () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
|
||||
const click = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: 'PR context',
|
||||
promptDelivery: 'draft'
|
||||
})
|
||||
const repeat = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: 'PR context',
|
||||
promptDelivery: 'draft'
|
||||
})
|
||||
|
||||
expect(repeat.sessionId).toBe(click.sessionId)
|
||||
expect(mocks.seedDraft).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('an empty chat still starting', () => {
|
||||
let resolveFirstLaunch!: (receipt: { sessionId: string; fence: number }) => void
|
||||
const third = launchIntent('session-third')
|
||||
const notesRequest = { prompt: 'review notes', promptDelivery: 'submit-after-ready' } as const
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
localStorage.clear()
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
mocks.createIntent
|
||||
.mockReturnValueOnce(first)
|
||||
.mockReturnValueOnce(second)
|
||||
.mockReturnValueOnce(third)
|
||||
clearNativeChatDraftCacheForTests()
|
||||
mocks.launch.mockImplementation((intent: StructuredAgentSessionLaunchIntent) =>
|
||||
intent.sessionId === first.sessionId
|
||||
? new Promise((resolve) => (resolveFirstLaunch = resolve))
|
||||
: Promise.resolve({ sessionId: intent.sessionId, fence: 1 })
|
||||
)
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
|
||||
publishedSnapshot(first.sessionId, second.sessionId, third.sessionId)
|
||||
])
|
||||
mocks.callStructuredAgentSession.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { submission: { dispatchState: 'accepted' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('takes notes sent to a new agent instead of opening a second chat', async () => {
|
||||
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
|
||||
// The notes menu stays enabled: its pick fills the empty chat rather than repeating a start.
|
||||
expect(
|
||||
getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex', structuredLaunchRequest(notesRequest))
|
||||
).toBe('idle')
|
||||
|
||||
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
|
||||
resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 })
|
||||
|
||||
expect(notes.sessionId).toBe(blank.sessionId)
|
||||
expect(mocks.createIntent).toHaveBeenCalledOnce()
|
||||
await expect(notes.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
expect(sends()).toEqual([[first.sessionId, 'review notes']])
|
||||
})
|
||||
|
||||
it('opens a new chat for any other request once its notes claimed it', async () => {
|
||||
startStructuredAgentLaunch(WORKTREE_ID, 'codex')
|
||||
startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
|
||||
|
||||
const fix = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: 'Fix check B',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
const pick = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
|
||||
|
||||
expect(fix.sessionId).toBe(second.sessionId)
|
||||
expect(pick.sessionId).toBe(third.sessionId)
|
||||
await expect(fix.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
expect(sends()).toEqual([[second.sessionId, 'Fix check B']])
|
||||
})
|
||||
|
||||
it('delivers the claiming text the way its own request asked', async () => {
|
||||
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { promptDelivery: 'draft' })
|
||||
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
|
||||
resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 })
|
||||
|
||||
await expect(notes.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
expect(sends()).toEqual([[first.sessionId, 'review notes']])
|
||||
expect(mocks.seedDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends the same notes once when they are sent again', async () => {
|
||||
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
|
||||
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
|
||||
const again = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
|
||||
resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 })
|
||||
|
||||
expect(again.sessionId).toBe(blank.sessionId)
|
||||
expect(mocks.createIntent).toHaveBeenCalledOnce()
|
||||
for (const caller of [notes, again]) {
|
||||
await expect(caller.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
}
|
||||
expect(sends()).toEqual([[first.sessionId, 'review notes']])
|
||||
})
|
||||
|
||||
it('leaves a chat its user already sent into to them, and opens a new chat for the notes', async () => {
|
||||
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
|
||||
appendStructuredAgentSessionOutboxMessage(blank.sessionId, 'my own question')
|
||||
|
||||
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
|
||||
|
||||
expect(notes.sessionId).toBe(second.sessionId)
|
||||
await expect(notes.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
expect(sends()).toEqual([[second.sessionId, 'review notes']])
|
||||
expect(readOutbox(blank.sessionId).map((entry) => entry.body.blocks)).toEqual([
|
||||
[{ type: 'text', text: 'my own question' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a chat its user is typing into to them, and opens a new chat for the notes', async () => {
|
||||
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
|
||||
const paneKey = structuredAgentSessionPaneKey(
|
||||
structuredAgentSessionTabId(blank.sessionId),
|
||||
blank.sessionId
|
||||
)
|
||||
writeNativeChatDraftCache(paneKey, 'half a question')
|
||||
|
||||
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
|
||||
|
||||
expect(notes.sessionId).toBe(second.sessionId)
|
||||
await expect(notes.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
expect(readOutbox(blank.sessionId)).toEqual([])
|
||||
expect(readNativeChatDraftCache(paneKey)).toBe('half a question')
|
||||
})
|
||||
|
||||
it('opens a new chat that shows the failure when the claiming text cannot be saved', async () => {
|
||||
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
|
||||
const storageFailure = vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
|
||||
throw new Error('storage unavailable')
|
||||
})
|
||||
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
|
||||
storageFailure.mockRestore()
|
||||
|
||||
// The new chat fails with its Retry line, the failure the caller is told was shown.
|
||||
expect(notes.sessionId).toBe(second.sessionId)
|
||||
await expect(notes.launchResult).rejects.toBeInstanceOf(
|
||||
StructuredAgentSessionCreateRefusalError
|
||||
)
|
||||
await expect(notes.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: false,
|
||||
failureNotified: true
|
||||
})
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, second.sessionId)).toBe('failed')
|
||||
// No claim was recorded: the blank chat is still blank and still claimable.
|
||||
expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex').sessionId).toBe(blank.sessionId)
|
||||
expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest).sessionId).toBe(
|
||||
blank.sessionId
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
structuredAgentSessionPaneKey,
|
||||
structuredAgentSessionTabId
|
||||
} from '../../../shared/structured-agent-session-projection'
|
||||
import { getStructuredAgentSessionOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage'
|
||||
import { readNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache'
|
||||
|
||||
/** A starting chat is empty until its user sends into it or types in its composer; after that it
|
||||
* is theirs, and another request's text never goes into it. */
|
||||
export function isStructuredLaunchChatEmpty(sessionId: string): boolean {
|
||||
const paneKey = structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId)
|
||||
return (
|
||||
getStructuredAgentSessionOutbox(sessionId).length === 0 &&
|
||||
readNativeChatDraftCache(paneKey).trim() === ''
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
launchStateLifecycle,
|
||||
structuredLaunchStates,
|
||||
type StructuredLaunchState
|
||||
} from './structured-agent-session-launch-registry'
|
||||
import {
|
||||
joinsFirstLaunchAttempt,
|
||||
type StructuredLaunchAttempt,
|
||||
type StructuredLaunchRequest
|
||||
} from './structured-agent-session-launch-request'
|
||||
import { isStructuredLaunchChatEmpty } from './structured-agent-session-launch-empty-chat'
|
||||
|
||||
// Why: coalescing stops a repeat of one request (a double click) racing into two chats. A different
|
||||
// request, a failed or unconfirmed launch, or a Retry/re-check of one is not that race: a new start
|
||||
// opens a new chat carrying its own text. A resume keeps holding: the host refuses a second adoption.
|
||||
function holdsLaunchIdentity(
|
||||
state: StructuredLaunchState,
|
||||
request?: StructuredLaunchRequest
|
||||
): boolean {
|
||||
const lifecycle = launchStateLifecycle(state)
|
||||
if (lifecycle === 'failed' || lifecycle === 'cancelled') {
|
||||
return false
|
||||
}
|
||||
if (state.intent.params.resumeFrom) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
lifecycle !== 'visibility-unknown' && joinsFirstLaunchAttempt(state.callers.attempt, request)
|
||||
)
|
||||
}
|
||||
|
||||
/** Launches a start of `request` would repeat; without `request`, every new start's own create. */
|
||||
export function structuredLaunchesHoldingIdentity(
|
||||
matches: (identity: string) => boolean,
|
||||
request?: StructuredLaunchRequest
|
||||
): StructuredLaunchState[] {
|
||||
return [...structuredLaunchStates()].filter(
|
||||
(state) => matches(state.identity) && holdsLaunchIdentity(state, request)
|
||||
)
|
||||
}
|
||||
|
||||
/** An empty chat (a + pick, the empty-workspace default) still starting: the first request with text
|
||||
* claims it once, and from then on it is that request's chat. A resume is never empty, nor a chat
|
||||
* its user has already sent or typed into. */
|
||||
export function claimableStructuredLaunchAttempt(
|
||||
state: StructuredLaunchState,
|
||||
request: StructuredLaunchRequest
|
||||
): Extract<StructuredLaunchAttempt, { kind: 'first' }> | undefined {
|
||||
const { attempt } = state.callers
|
||||
return !state.intent.params.resumeFrom &&
|
||||
attempt.kind === 'first' &&
|
||||
attempt.request.text === '' &&
|
||||
request.text !== '' &&
|
||||
isStructuredLaunchChatEmpty(state.intent.sessionId)
|
||||
? attempt
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** The launch a new start of `request` joins: one it repeats, else an empty chat it claims. The
|
||||
* newest wins if a retried resume holds the identity too. */
|
||||
export function getJoinableStructuredLaunchState(
|
||||
identity: string,
|
||||
request: StructuredLaunchRequest
|
||||
): StructuredLaunchState | undefined {
|
||||
const matches = (candidate: string): boolean => candidate === identity
|
||||
return (
|
||||
structuredLaunchesHoldingIdentity(matches, request).at(-1) ??
|
||||
structuredLaunchesHoldingIdentity(matches).findLast((state) =>
|
||||
claimableStructuredLaunchAttempt(state, request)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -137,7 +137,9 @@ describe('coalesced launch delivery mode', () => {
|
||||
established: StructuredAgentLaunchOptions
|
||||
joining: StructuredAgentLaunchOptions
|
||||
}) {
|
||||
const intent = launchIntent(args.worktreeId, args.sessionId)
|
||||
const base = launchIntent(args.worktreeId, args.sessionId)
|
||||
const { resumeFrom } = args.established
|
||||
const intent = resumeFrom ? { ...base, params: { ...base.params, resumeFrom } } : base
|
||||
let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
|
||||
mocks.createIntent.mockReturnValueOnce(intent)
|
||||
mocks.launch.mockImplementation(
|
||||
@@ -155,13 +157,14 @@ describe('coalesced launch delivery mode', () => {
|
||||
return { intent, joiner }
|
||||
}
|
||||
|
||||
it('keeps a joiner as a draft when the launch it joined established no mode', async () => {
|
||||
// The onboarding folder launch establishes an identity carrying neither prompt nor mode.
|
||||
// Only a resume joins a launch started by a different request; a new start opens its own chat.
|
||||
it('keeps a resume joiner as a draft when the launch it joined established no mode', async () => {
|
||||
const resumeFrom = { providerSessionId: 'provider-unset-delivery' }
|
||||
const { intent, joiner } = await coalesce({
|
||||
worktreeId: 'wt-unset-delivery-mode',
|
||||
sessionId: 'unset-delivery-session',
|
||||
established: {},
|
||||
joining: { prompt: 'PR context', promptDelivery: 'draft' }
|
||||
established: { resumeFrom },
|
||||
joining: { resumeFrom, prompt: 'PR context', promptDelivery: 'draft' }
|
||||
})
|
||||
|
||||
// Why: an unset established mode must not read as submit; the joiner never consented to send.
|
||||
|
||||
@@ -43,19 +43,17 @@ export type StructuredAgentSessionLaunchLifecycle =
|
||||
| 'published'
|
||||
| 'cancelled'
|
||||
|
||||
const pendingStructuredLaunchesByIdentity = new Map<string, StructuredLaunchState>()
|
||||
const structuredLaunchesBySessionId = new Map<string, StructuredLaunchState>()
|
||||
const structuredLaunchListeners = new Set<() => void>()
|
||||
|
||||
export function resetStructuredAgentLaunchRegistryForTests(): void {
|
||||
pendingStructuredLaunchesByIdentity.clear()
|
||||
structuredLaunchesBySessionId.clear()
|
||||
structuredLaunchListeners.clear()
|
||||
resetStructuredAgentLaunchCancellationForTests()
|
||||
}
|
||||
|
||||
export function notifyStructuredLaunchListeners(): void {
|
||||
for (const state of pendingStructuredLaunchesByIdentity.values()) {
|
||||
for (const state of structuredLaunchesBySessionId.values()) {
|
||||
persistStructuredLaunchState(state)
|
||||
}
|
||||
for (const listener of structuredLaunchListeners) {
|
||||
@@ -80,10 +78,6 @@ export function structuredLaunchIdentity(
|
||||
: `${agent}:${worktreeId}`
|
||||
}
|
||||
|
||||
export function getStructuredLaunchState(identity: string): StructuredLaunchState | undefined {
|
||||
return pendingStructuredLaunchesByIdentity.get(identity)
|
||||
}
|
||||
|
||||
export function getStructuredLaunchStateBySessionId(
|
||||
sessionId: string
|
||||
): StructuredLaunchState | undefined {
|
||||
@@ -91,19 +85,15 @@ export function getStructuredLaunchStateBySessionId(
|
||||
}
|
||||
|
||||
export function setStructuredLaunchState(state: StructuredLaunchState): void {
|
||||
pendingStructuredLaunchesByIdentity.set(state.identity, state)
|
||||
structuredLaunchesBySessionId.set(state.intent.sessionId, state)
|
||||
persistStructuredLaunchState(state)
|
||||
}
|
||||
|
||||
export function deleteStructuredLaunchStateIfCurrent(state: StructuredLaunchState): boolean {
|
||||
if (pendingStructuredLaunchesByIdentity.get(state.identity) !== state) {
|
||||
if (structuredLaunchesBySessionId.get(state.intent.sessionId) !== state) {
|
||||
return false
|
||||
}
|
||||
pendingStructuredLaunchesByIdentity.delete(state.identity)
|
||||
if (structuredLaunchesBySessionId.get(state.intent.sessionId) === state) {
|
||||
structuredLaunchesBySessionId.delete(state.intent.sessionId)
|
||||
}
|
||||
structuredLaunchesBySessionId.delete(state.intent.sessionId)
|
||||
deleteStructuredAgentLaunchRecord(state.intent.sessionId)
|
||||
return true
|
||||
}
|
||||
@@ -124,10 +114,12 @@ export function getPersistedStructuredAgentLaunchRecord(
|
||||
}
|
||||
|
||||
export function structuredLaunchStates(): IterableIterator<StructuredLaunchState> {
|
||||
return pendingStructuredLaunchesByIdentity.values()
|
||||
return structuredLaunchesBySessionId.values()
|
||||
}
|
||||
|
||||
function launchStateLifecycle(state: StructuredLaunchState): StructuredAgentSessionLaunchLifecycle {
|
||||
export function launchStateLifecycle(
|
||||
state: StructuredLaunchState
|
||||
): StructuredAgentSessionLaunchLifecycle {
|
||||
if (state.cancelled || state.callers.outcome === 'cancelled') {
|
||||
return 'cancelled'
|
||||
}
|
||||
@@ -314,20 +306,3 @@ export function retireAbsentStructuredAgentSessionLaunchCancellationTombstones(
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
export function getStructuredAgentLaunchStatus(
|
||||
worktreeId: string,
|
||||
agent: AgentSessionHandleProvider
|
||||
): StructuredAgentLaunchStatus {
|
||||
// Any launch for this pair, including adopted conversations, means a chat is starting here.
|
||||
const states = [
|
||||
getStructuredLaunchState(structuredLaunchIdentity(worktreeId, agent)),
|
||||
...[...pendingStructuredLaunchesByIdentity.entries()]
|
||||
.filter(([identity]) => identity.startsWith(`${agent}:${worktreeId}:resume:`))
|
||||
.map(([, state]) => state)
|
||||
].filter((state): state is StructuredLaunchState => Boolean(state))
|
||||
if (states.length === 0) {
|
||||
return 'idle'
|
||||
}
|
||||
return states.some((state) => state.visibilityUnknown) ? 'unknown' : 'pending'
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ export function restorePersistedStructuredLaunchState(
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const callers: StructuredLaunchCallerGroup = createStructuredLaunchCallerGroup()
|
||||
// Only a Retry or re-check restarts a restored launch.
|
||||
const callers: StructuredLaunchCallerGroup = createStructuredLaunchCallerGroup({ kind: 'retry' })
|
||||
const state: StructuredLaunchState = {
|
||||
identity: structuredLaunchIdentity(worktreeId, record.agent, record.resumeFrom),
|
||||
intent,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox'
|
||||
|
||||
/** What a new start asks its chat to receive. Callers carry no request id, so this is what tells a
|
||||
* repeat of one request (a double click, a retried call) from a different request. */
|
||||
export type StructuredLaunchRequest = { text: string; draft: boolean }
|
||||
|
||||
/** A new start's own create keeps its request and the text it staged; a Retry or re-check of an
|
||||
* existing chat is no request of its own. */
|
||||
export type StructuredLaunchAttempt =
|
||||
| {
|
||||
kind: 'first'
|
||||
request: StructuredLaunchRequest
|
||||
stagedEntry: StructuredAgentSessionOutboxEntry | null
|
||||
}
|
||||
| { kind: 'retry' }
|
||||
|
||||
/** A pick that carries no text, as the + menu and new-tab search make. */
|
||||
export const BLANK_STRUCTURED_LAUNCH_REQUEST: StructuredLaunchRequest = { text: '', draft: false }
|
||||
|
||||
export function structuredLaunchRequest(options: {
|
||||
prompt?: string
|
||||
promptDelivery?: string
|
||||
}): StructuredLaunchRequest {
|
||||
const text = options.prompt?.trim() ?? ''
|
||||
// Without text the delivery mode carries nothing: two blank starts are one request.
|
||||
return { text, draft: text !== '' && options.promptDelivery === 'draft' }
|
||||
}
|
||||
|
||||
/** The first attempt `request` repeats, whose text is already staged or seeded. */
|
||||
export function repeatedStructuredLaunchAttempt(
|
||||
attempt: StructuredLaunchAttempt,
|
||||
request: StructuredLaunchRequest
|
||||
): Extract<StructuredLaunchAttempt, { kind: 'first' }> | undefined {
|
||||
return attempt.kind === 'first' &&
|
||||
attempt.request.text === request.text &&
|
||||
attempt.request.draft === request.draft
|
||||
? attempt
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Only a new start's own create is joined, and only by a repeat of its request. Without `request`,
|
||||
* any new start's own create counts. */
|
||||
export function joinsFirstLaunchAttempt(
|
||||
attempt: StructuredLaunchAttempt,
|
||||
request?: StructuredLaunchRequest
|
||||
): boolean {
|
||||
return (
|
||||
attempt.kind === 'first' &&
|
||||
(!request || repeatedStructuredLaunchAttempt(attempt, request) !== undefined)
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,40 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
|
||||
import { structuredLaunchesHoldingIdentity } from './structured-agent-session-launch-holders'
|
||||
import {
|
||||
getStructuredAgentLaunchStatus,
|
||||
subscribeStructuredAgentLaunchStatus
|
||||
structuredLaunchIdentity,
|
||||
subscribeStructuredAgentLaunchStatus,
|
||||
type StructuredAgentLaunchStatus
|
||||
} from './structured-agent-session-launch-registry'
|
||||
import type { StructuredLaunchRequest } from './structured-agent-session-launch-request'
|
||||
|
||||
/** With `request`, only launches a start of it would repeat: any other request is new work. */
|
||||
export function getStructuredAgentLaunchStatus(
|
||||
worktreeId: string,
|
||||
agent: AgentSessionHandleProvider,
|
||||
request?: StructuredLaunchRequest
|
||||
): StructuredAgentLaunchStatus {
|
||||
// Any launch holding an identity for this pair, adopted conversations included, is starting here.
|
||||
// A failed chat is not, nor an unconfirmed or retried blank one: a new launch opens its own chat.
|
||||
const identity = structuredLaunchIdentity(worktreeId, agent)
|
||||
const states = structuredLaunchesHoldingIdentity(
|
||||
(candidate) => candidate === identity || candidate.startsWith(`${identity}:resume:`),
|
||||
request
|
||||
)
|
||||
if (states.length === 0) {
|
||||
return 'idle'
|
||||
}
|
||||
return states.some((state) => state.visibilityUnknown) ? 'unknown' : 'pending'
|
||||
}
|
||||
|
||||
export function useStructuredAgentLaunchStatus(
|
||||
worktreeId: string,
|
||||
agent: AgentSessionHandleProvider
|
||||
): ReturnType<typeof getStructuredAgentLaunchStatus> {
|
||||
agent: AgentSessionHandleProvider,
|
||||
request?: StructuredLaunchRequest
|
||||
): StructuredAgentLaunchStatus {
|
||||
return useSyncExternalStore(
|
||||
subscribeStructuredAgentLaunchStatus,
|
||||
() => getStructuredAgentLaunchStatus(worktreeId, agent),
|
||||
() => getStructuredAgentLaunchStatus(worktreeId, agent, request),
|
||||
() => 'idle'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
|
||||
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
abandonIntent: vi.fn(),
|
||||
callStructuredAgentSession: vi.fn(),
|
||||
createIntent: vi.fn(),
|
||||
retryIntent: vi.fn(),
|
||||
restoreIntent: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
seedDraft: vi.fn(),
|
||||
clearDraft: vi.fn(),
|
||||
toastError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } }))
|
||||
|
||||
vi.mock('@/lib/launch-structured-agent-session', () => {
|
||||
class StructuredAgentSessionCreateRefusalError extends Error {}
|
||||
return {
|
||||
createStructuredAgentSessionLaunchIntent: mocks.createIntent,
|
||||
retryStructuredAgentSessionLaunchIntent: mocks.retryIntent,
|
||||
restoreStructuredAgentSessionLaunchIntent: mocks.restoreIntent,
|
||||
abandonStructuredAgentSessionLaunchIntent: mocks.abandonIntent,
|
||||
launchStructuredAgentSession: mocks.launch,
|
||||
StructuredAgentSessionCreateRefusalError
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
|
||||
refreshLocalStructuredSessionTabs: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.callStructuredAgentSession
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
unifiedTabsByWorktree: {},
|
||||
seedNativeChatLaunchDraft: mocks.seedDraft,
|
||||
clearNativeChatLaunchDraft: mocks.clearDraft
|
||||
}),
|
||||
subscribe: () => () => undefined
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-catalog', () => ({
|
||||
getAgentLabel: () => 'Codex',
|
||||
getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }]
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() }))
|
||||
|
||||
// The structured route hands the launch's own delivery result back unchanged.
|
||||
vi.mock('@/lib/launch-agent-in-new-tab', async () => {
|
||||
const launch = await import('./structured-agent-session-launch')
|
||||
return {
|
||||
launchAgentInNewTab: (args: {
|
||||
worktreeId: string
|
||||
prompt: string
|
||||
promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready'
|
||||
}) => {
|
||||
const started = launch.startStructuredAgentLaunch(args.worktreeId, 'codex', {
|
||||
prompt: args.prompt,
|
||||
promptDelivery: args.promptDelivery
|
||||
})
|
||||
return {
|
||||
surface: { kind: 'agent-session', sessionId: started.sessionId },
|
||||
...(started.promptDeliveryResult
|
||||
? { promptDeliveryResult: started.promptDeliveryResult }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
|
||||
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
|
||||
import { runSourceControlAgentActionStart } from '@/components/right-sidebar/runSourceControlAgentActionStart'
|
||||
import {
|
||||
getStructuredAgentLaunchStatus,
|
||||
getStructuredAgentSessionLaunchLifecycle,
|
||||
retryStructuredAgentSessionLaunch,
|
||||
startStructuredAgentLaunch
|
||||
} from './structured-agent-session-launch'
|
||||
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
|
||||
import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry'
|
||||
|
||||
const WORKTREE_ID = 'wt-unconfirmed-or-retried'
|
||||
|
||||
function launchIntent(sessionId: string): StructuredAgentSessionLaunchIntent {
|
||||
return {
|
||||
worktreeId: WORKTREE_ID,
|
||||
sessionId,
|
||||
executionHostId: 'local',
|
||||
target: { kind: 'local' },
|
||||
agent: 'codex',
|
||||
params: {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: `operation-${sessionId}`,
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: `fingerprint-${sessionId}`
|
||||
},
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
agent: 'codex'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function publishedSnapshot(sessionId: string): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: [
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: `tab-${sessionId}`,
|
||||
title: 'Codex',
|
||||
sessionId,
|
||||
agent: 'codex',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async function flushLaunchSettlement(): Promise<void> {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
const unconfirmed = launchIntent('session-unconfirmed')
|
||||
const fresh = launchIntent('session-new')
|
||||
const resumeFrom = { providerSessionId: 'provider-1' }
|
||||
|
||||
/** The create's answer is lost and inventory never shows the chat: it stays unconfirmed. */
|
||||
async function leaveFirstLaunchUnconfirmed(options: { resume?: boolean } = {}): Promise<void> {
|
||||
mocks.createIntent
|
||||
.mockReturnValueOnce(
|
||||
options.resume
|
||||
? { ...unconfirmed, params: { ...unconfirmed.params, resumeFrom } }
|
||||
: unconfirmed
|
||||
)
|
||||
.mockReturnValueOnce(fresh)
|
||||
startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: 'first task',
|
||||
...(options.resume ? { resumeFrom } : {})
|
||||
})
|
||||
await flushLaunchSettlement()
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, unconfirmed.sessionId)).toBe(
|
||||
'visibility-unknown'
|
||||
)
|
||||
}
|
||||
|
||||
function expectSentTo(sessionId: string, text: string): void {
|
||||
expect(mocks.callStructuredAgentSession).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'agentSession.send',
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({ sessionId }),
|
||||
body: expect.objectContaining({ blocks: [{ type: 'text', text }] })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('a new start beside an unconfirmed or retried chat', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
localStorage.clear()
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
mocks.retryIntent.mockImplementation((intent: StructuredAgentSessionLaunchIntent) => intent)
|
||||
mocks.restoreIntent.mockImplementation((args: { sessionId: string }) =>
|
||||
launchIntent(args.sessionId)
|
||||
)
|
||||
mocks.launch.mockImplementation((intent: StructuredAgentSessionLaunchIntent) =>
|
||||
intent.sessionId === fresh.sessionId
|
||||
? Promise.resolve({ sessionId: fresh.sessionId, fence: 1 })
|
||||
: Promise.reject(new Error('response lost'))
|
||||
)
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
|
||||
publishedSnapshot(fresh.sessionId)
|
||||
])
|
||||
mocks.callStructuredAgentSession.mockResolvedValue({
|
||||
ok: true,
|
||||
page: { fence: 1 },
|
||||
value: { submission: { dispatchState: 'accepted' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('starts a source-control agent in a new chat and reports success only once its text is sent', async () => {
|
||||
await leaveFirstLaunchUnconfirmed()
|
||||
const onLaunched = vi.fn()
|
||||
|
||||
const started = await runSourceControlAgentActionStart({
|
||||
selectedAgent: 'codex',
|
||||
trimmedCommandInput: 'Fix the failing check',
|
||||
agentArgs: '',
|
||||
agentArgsApply: false,
|
||||
commandTemplate: '{basePrompt}',
|
||||
saveTargetValue: 'none',
|
||||
actionId: 'resolveComments',
|
||||
settings: null,
|
||||
repo: null,
|
||||
worktreeId: WORKTREE_ID,
|
||||
promptDelivery: 'submit-after-ready',
|
||||
launchSource: 'source_control_recovery',
|
||||
onLaunched,
|
||||
onClose: vi.fn()
|
||||
})
|
||||
|
||||
expect(started).toBe(true)
|
||||
expect(onLaunched).toHaveBeenCalledOnce()
|
||||
expect(mocks.createIntent).toHaveBeenCalledTimes(2)
|
||||
expectSentTo(fresh.sessionId, 'Fix the failing check')
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends notes to a new chat while another chat is unconfirmed', async () => {
|
||||
await leaveFirstLaunchUnconfirmed()
|
||||
|
||||
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: 'review notes',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
|
||||
expect(notes.sessionId).toBe(fresh.sessionId)
|
||||
await expect(notes.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
expectSentTo(fresh.sessionId, 'review notes')
|
||||
// The unconfirmed chat is left for its own re-check.
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, unconfirmed.sessionId)).toBe(
|
||||
'visibility-unknown'
|
||||
)
|
||||
})
|
||||
|
||||
it("opens a new chat with its text, not a draft, while a restored chat's Retry is in flight", async () => {
|
||||
const failed = launchIntent('session-failed')
|
||||
mocks.createIntent.mockReturnValueOnce(failed).mockReturnValueOnce(fresh)
|
||||
mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
|
||||
startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'first task' })
|
||||
await flushLaunchSettlement()
|
||||
// Reload: the registry is memory; the failed record is what survives.
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
mocks.launch.mockImplementationOnce(() => new Promise(() => undefined))
|
||||
expect(retryStructuredAgentSessionLaunch(WORKTREE_ID, failed.sessionId)).toBe(true)
|
||||
mocks.seedDraft.mockClear()
|
||||
|
||||
const next = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: 'fix it',
|
||||
promptDelivery: 'submit-after-ready'
|
||||
})
|
||||
|
||||
expect(next.sessionId).toBe(fresh.sessionId)
|
||||
await expect(next.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
expectSentTo(fresh.sessionId, 'fix it')
|
||||
expect(mocks.seedDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a new chat from a + menu pick while another chat is unconfirmed', async () => {
|
||||
await leaveFirstLaunchUnconfirmed()
|
||||
// Why: the + menu disables an agent only while a pick would join a start in flight.
|
||||
expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('idle')
|
||||
|
||||
const pick = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
|
||||
|
||||
expect(pick.sessionId).toBe(fresh.sessionId)
|
||||
expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('pending')
|
||||
// No re-check of the unconfirmed chat: only its own two create attempts and the new one.
|
||||
expect(mocks.launch).toHaveBeenCalledTimes(3)
|
||||
await expect(pick.launchResult).resolves.toEqual({ sessionId: fresh.sessionId, fence: 1 })
|
||||
})
|
||||
|
||||
it('still coalesces a repeat of one request racing for one chat', async () => {
|
||||
mocks.createIntent.mockReturnValueOnce(fresh)
|
||||
mocks.launch.mockImplementationOnce(() => new Promise(() => undefined))
|
||||
|
||||
const first = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'one' })
|
||||
const second = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'one' })
|
||||
|
||||
expect(second.sessionId).toBe(first.sessionId)
|
||||
expect(mocks.createIntent).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('still re-checks an unconfirmed resume instead of adopting its conversation twice', async () => {
|
||||
await leaveFirstLaunchUnconfirmed({ resume: true })
|
||||
expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('unknown')
|
||||
|
||||
expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex', { resumeFrom }).sessionId).toBe(
|
||||
unconfirmed.sessionId
|
||||
)
|
||||
expect(mocks.createIntent).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -425,7 +425,7 @@ describe('startStructuredAgentLaunch', () => {
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delivers a prompt from a coalesced caller after the shared launch settles', async () => {
|
||||
it("delivers a repeated request's prompt once, to both callers, after the shared launch settles", async () => {
|
||||
const worktreeId = 'wt-coalesced-prompt'
|
||||
const intent = launchIntent(worktreeId)
|
||||
let resolveLaunch: (receipt: { sessionId: string; fence: number }) => void = () => {}
|
||||
@@ -442,16 +442,23 @@ describe('startStructuredAgentLaunch', () => {
|
||||
ok: true,
|
||||
value: { submission: { dispatchState: 'accepted' } }
|
||||
})
|
||||
startStructuredAgentLaunch(worktreeId, 'codex')
|
||||
const first = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
|
||||
const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
|
||||
|
||||
resolveLaunch({ sessionId: intent.sessionId, fence: 1 })
|
||||
await expect(second.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
for (const caller of [first, second]) {
|
||||
await expect(caller.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
}
|
||||
await flushLaunchSettlement()
|
||||
|
||||
expect(second.sessionId).toBe(first.sessionId)
|
||||
expect(
|
||||
mocks.callStructuredAgentSession.mock.calls.filter((call) => call[1] === 'agentSession.send')
|
||||
).toHaveLength(1)
|
||||
|
||||
expect(mocks.callStructuredAgentSession).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'agentSession.send',
|
||||
@@ -467,36 +474,42 @@ describe('startStructuredAgentLaunch', () => {
|
||||
const worktreeId = 'wt-coalesced-prompt-reservation'
|
||||
const intent = launchIntent(worktreeId)
|
||||
let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
|
||||
let resolveDelivery!: (result: {
|
||||
ok: true
|
||||
value: { submission: { dispatchState: 'accepted' } }
|
||||
}) => void
|
||||
const pendingSends: ((result: unknown) => void)[] = []
|
||||
mocks.createIntent.mockReturnValue(intent)
|
||||
mocks.launch.mockImplementationOnce(() => new Promise((resolve) => (resolveLaunch = resolve)))
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
|
||||
publishedSnapshot(worktreeId, intent.sessionId)
|
||||
])
|
||||
mocks.callStructuredAgentSession.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveDelivery = resolve))
|
||||
// Every send waits, so a second send of the repeated text would show up below.
|
||||
mocks.callStructuredAgentSession.mockImplementation(
|
||||
() => new Promise((resolve) => pendingSends.push(resolve))
|
||||
)
|
||||
|
||||
startStructuredAgentLaunch(worktreeId, 'codex')
|
||||
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
|
||||
const coalesced = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
|
||||
resolveLaunch({ sessionId: intent.sessionId, fence: 1 })
|
||||
await vi.waitFor(() => expect(mocks.callStructuredAgentSession).toHaveBeenCalledOnce())
|
||||
|
||||
startStructuredAgentLaunch(worktreeId, 'codex')
|
||||
const whileSending = startStructuredAgentLaunch(worktreeId, 'codex', {
|
||||
prompt: 'second prompt'
|
||||
})
|
||||
expect(mocks.createIntent).toHaveBeenCalledOnce()
|
||||
expect(mocks.launch).toHaveBeenCalledOnce()
|
||||
|
||||
resolveDelivery({ ok: true, value: { submission: { dispatchState: 'accepted' } } })
|
||||
await expect(coalesced.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
await flushLaunchSettlement()
|
||||
for (const resolve of pendingSends) {
|
||||
resolve({ ok: true, value: { submission: { dispatchState: 'accepted' } } })
|
||||
}
|
||||
for (const caller of [coalesced, whileSending]) {
|
||||
await expect(caller.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
}
|
||||
expect(mocks.callStructuredAgentSession).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps one launch identity per worktree while the outcome is unknown', async () => {
|
||||
it('opens a new chat for a new start while an earlier outcome is unknown', async () => {
|
||||
const worktreeId = 'wt-unknown-different-prompts'
|
||||
const intent = launchIntent(worktreeId)
|
||||
mocks.createIntent.mockReturnValueOnce(intent)
|
||||
@@ -505,10 +518,16 @@ describe('startStructuredAgentLaunch', () => {
|
||||
|
||||
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
|
||||
await flushLaunchSettlement()
|
||||
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
|
||||
const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
|
||||
await flushLaunchSettlement()
|
||||
|
||||
expect(mocks.createIntent).toHaveBeenCalledOnce()
|
||||
expect(mocks.createIntent).toHaveBeenCalledTimes(2)
|
||||
expect(second.sessionId).not.toBe(intent.sessionId)
|
||||
expect(readOutbox(second.sessionId)).toEqual([
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({ blocks: [{ type: 'text', text: 'second prompt' }] })
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('reconciles a host commit when the create reply is lost', async () => {
|
||||
@@ -572,20 +591,22 @@ describe('startStructuredAgentLaunch', () => {
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an unresolved identity reserved until inventory reconciles it', async () => {
|
||||
// Why a resume: the host refuses a second adoption of the conversation, so a new resume re-checks.
|
||||
it('keeps an unresolved resume identity reserved until inventory reconciles it', async () => {
|
||||
const worktreeId = 'wt-still-unknown'
|
||||
const resumeFrom = { providerSessionId: 'provider-still-unknown' }
|
||||
const intent = launchIntent(worktreeId)
|
||||
mocks.createIntent.mockReturnValueOnce(intent)
|
||||
mocks.createIntent.mockReturnValueOnce({ ...intent, params: { ...intent.params, resumeFrom } })
|
||||
mocks.launch.mockRejectedValue(new Error('offline'))
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
|
||||
|
||||
startStructuredAgentLaunch(worktreeId, 'codex')
|
||||
startStructuredAgentLaunch(worktreeId, 'codex', { resumeFrom })
|
||||
await flushLaunchSettlement()
|
||||
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
|
||||
publishedSnapshot(worktreeId, intent.sessionId)
|
||||
])
|
||||
startStructuredAgentLaunch(worktreeId, 'codex')
|
||||
startStructuredAgentLaunch(worktreeId, 'codex', { resumeFrom })
|
||||
await flushLaunchSettlement()
|
||||
|
||||
expect(mocks.createIntent).toHaveBeenCalledOnce()
|
||||
@@ -607,8 +628,10 @@ describe('startStructuredAgentLaunch', () => {
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
|
||||
publishedSnapshot(worktreeId, intent.sessionId)
|
||||
])
|
||||
const retry = startStructuredAgentLaunch(worktreeId, 'codex')
|
||||
await expect(retry.launchResult).resolves.toEqual({ sessionId: intent.sessionId, fence: 1 })
|
||||
// The chat's own Retry re-checks it; a new start would open a new chat instead.
|
||||
expect(retryStructuredAgentSessionLaunch(worktreeId, intent.sessionId)).toBe(true)
|
||||
await flushLaunchSettlement()
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(worktreeId, intent.sessionId)).toBeNull()
|
||||
|
||||
expect(readOutbox(intent.sessionId)).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -657,7 +680,7 @@ describe('startStructuredAgentLaunch', () => {
|
||||
|
||||
startStructuredAgentLaunch(worktreeId, 'codex')
|
||||
await flushLaunchSettlement()
|
||||
startStructuredAgentLaunch(worktreeId, 'codex')
|
||||
expect(retryStructuredAgentSessionLaunch(worktreeId, first.sessionId)).toBe(true)
|
||||
await flushLaunchSettlement()
|
||||
|
||||
expect(mocks.createIntent).toHaveBeenCalledOnce()
|
||||
@@ -670,34 +693,6 @@ describe('startStructuredAgentLaunch', () => {
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not stage the preserved launch prompt again on retry', async () => {
|
||||
const worktreeId = 'wt-refused-prompt-retry'
|
||||
const intent = launchIntent(worktreeId, 'session-refused-prompt-retry')
|
||||
mocks.createIntent.mockReturnValueOnce(intent)
|
||||
mocks.launch
|
||||
.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
|
||||
.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
|
||||
publishedSnapshot(worktreeId, intent.sessionId)
|
||||
])
|
||||
|
||||
startStructuredAgentLaunch(worktreeId, 'codex', {
|
||||
prompt: 'only once',
|
||||
promptDelivery: 'draft'
|
||||
})
|
||||
await flushLaunchSettlement()
|
||||
expect(readOutbox(intent.sessionId)).toEqual([])
|
||||
|
||||
const retry = startStructuredAgentLaunch(worktreeId, 'codex', {
|
||||
prompt: 'only once',
|
||||
promptDelivery: 'draft'
|
||||
})
|
||||
await expect(retry.launchResult).resolves.toEqual({ sessionId: intent.sessionId, fence: 1 })
|
||||
|
||||
expect(readOutbox(intent.sessionId)).toEqual([])
|
||||
expect(mocks.seedDraft).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// A paired server's create seeds from its settings at create time, which its probe reports.
|
||||
it("shows the seed a paired server's probe reports on a retry, not the first admission's", async () => {
|
||||
const worktreeId = 'wt-paired-retry-seed'
|
||||
@@ -781,7 +776,7 @@ describe('startStructuredAgentLaunch', () => {
|
||||
storageFailure.mockRestore()
|
||||
})
|
||||
|
||||
it('reports every coalesced prompt as undelivered after refusal', async () => {
|
||||
it("reports a repeated request's prompt as undelivered to both callers after refusal", async () => {
|
||||
const worktreeId = 'wt-refused-coalesced-prompts'
|
||||
const intent = launchIntent(worktreeId)
|
||||
let rejectLaunch!: (error: unknown) => void
|
||||
@@ -791,8 +786,8 @@ describe('startStructuredAgentLaunch', () => {
|
||||
)
|
||||
|
||||
const first = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
|
||||
const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
|
||||
expect(readOutbox(intent.sessionId)).toHaveLength(2)
|
||||
const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
|
||||
expect(readOutbox(intent.sessionId)).toHaveLength(1)
|
||||
|
||||
rejectLaunch(new StructuredAgentSessionCreateRefusalError('unsupported'))
|
||||
await expect(first.launchResult).rejects.toBeInstanceOf(
|
||||
@@ -806,41 +801,44 @@ describe('startStructuredAgentLaunch', () => {
|
||||
delivered: false,
|
||||
failureNotified: true
|
||||
})
|
||||
expect(readOutbox(intent.sessionId)).toHaveLength(2)
|
||||
expect(readOutbox(intent.sessionId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('delivers a coalesced caller the way the launch it joined already decided', async () => {
|
||||
it('opens a new chat for the same text asked to be sent instead of drafted', async () => {
|
||||
const worktreeId = 'wt-coalesced-delivery-mode'
|
||||
const intent = launchIntent(worktreeId, 'coalesced-delivery-session')
|
||||
let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
|
||||
mocks.createIntent.mockReturnValueOnce(intent)
|
||||
mocks.launch.mockImplementation(
|
||||
() =>
|
||||
new Promise<{ sessionId: string; fence: number }>((resolve) => (resolveLaunch = resolve))
|
||||
const drafted = launchIntent(worktreeId, 'coalesced-delivery-session')
|
||||
const sent = launchIntent(worktreeId, 'sent-delivery-session')
|
||||
mocks.createIntent.mockReturnValueOnce(drafted).mockReturnValueOnce(sent)
|
||||
mocks.launch.mockImplementation((intent: StructuredAgentSessionLaunchIntent) =>
|
||||
Promise.resolve({ sessionId: intent.sessionId, fence: 1 })
|
||||
)
|
||||
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
|
||||
publishedSnapshot(worktreeId, intent.sessionId)
|
||||
publishedSnapshot(worktreeId, drafted.sessionId),
|
||||
publishedSnapshot(worktreeId, sent.sessionId)
|
||||
])
|
||||
mocks.callStructuredAgentSession.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { submission: { dispatchState: 'accepted' } }
|
||||
})
|
||||
|
||||
startStructuredAgentLaunch(worktreeId, 'codex', {
|
||||
prompt: 'PR #1 context',
|
||||
promptDelivery: 'draft'
|
||||
})
|
||||
const joiner = startStructuredAgentLaunch(worktreeId, 'codex', {
|
||||
const other = startStructuredAgentLaunch(worktreeId, 'codex', {
|
||||
prompt: 'PR #1 context',
|
||||
promptDelivery: 'auto-submit'
|
||||
})
|
||||
resolveLaunch({ sessionId: intent.sessionId, fence: 1 })
|
||||
await flushLaunchSettlement()
|
||||
|
||||
// Why: the first caller's seed is already in the composer, so submitting the joiner's copy
|
||||
// would show the user the text AND send it.
|
||||
expect(readOutbox(intent.sessionId)).toEqual([])
|
||||
expect(joiner.promptDeliveryResult).toBeUndefined()
|
||||
expect(
|
||||
mocks.callStructuredAgentSession.mock.calls.some((call) => call[1] === 'agentSession.send')
|
||||
).toBe(false)
|
||||
expect(mocks.seedDraft).toHaveBeenLastCalledWith(
|
||||
// Why: a draft and a send are different requests; neither lands in the other's chat.
|
||||
expect(other.sessionId).toBe(sent.sessionId)
|
||||
await expect(other.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
expect(readOutbox(drafted.sessionId)).toEqual([])
|
||||
expect(mocks.seedDraft).toHaveBeenCalledOnce()
|
||||
expect(mocks.seedDraft).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tabId: 'structured-agent-session-coalesced-delivery-session',
|
||||
text: 'PR #1 context'
|
||||
|
||||
@@ -29,7 +29,6 @@ import * as launchDraft from './structured-agent-session-launch-draft'
|
||||
import {
|
||||
deleteStructuredLaunchStateIfCurrent,
|
||||
getStructuredAgentSessionLaunchLifecycle,
|
||||
getStructuredLaunchState,
|
||||
getStructuredLaunchStateBySessionId,
|
||||
markStructuredAgentSessionLaunchCancelled,
|
||||
notifyStructuredLaunchListeners,
|
||||
@@ -38,12 +37,20 @@ import {
|
||||
type StructuredLaunchState
|
||||
} from './structured-agent-session-launch-registry'
|
||||
import { restorePersistedStructuredLaunchState } from './structured-agent-session-launch-reload'
|
||||
import {
|
||||
claimableStructuredLaunchAttempt,
|
||||
getJoinableStructuredLaunchState
|
||||
} from './structured-agent-session-launch-holders'
|
||||
import { applyStructuredLaunchHeldOptions } from './structured-agent-session-launch-options'
|
||||
import { trackLaunchSettlement } from './structured-agent-session-launch-outcome-tracking'
|
||||
import {
|
||||
repeatedStructuredLaunchAttempt,
|
||||
structuredLaunchRequest,
|
||||
type StructuredLaunchRequest
|
||||
} from './structured-agent-session-launch-request'
|
||||
|
||||
export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt }
|
||||
export {
|
||||
getStructuredAgentLaunchStatus,
|
||||
getStructuredAgentSessionLaunchLifecycle,
|
||||
getStructuredAgentSessionLaunchResumes,
|
||||
hasStructuredAgentSessionLaunchCancellationTombstone,
|
||||
@@ -56,7 +63,7 @@ export {
|
||||
type StructuredAgentLaunchStatus,
|
||||
type StructuredAgentSessionLaunchLifecycle
|
||||
} from './structured-agent-session-launch-registry'
|
||||
export { useStructuredAgentLaunchStatus } from './structured-agent-session-launch-status'
|
||||
export * from './structured-agent-session-launch-status'
|
||||
export { useStructuredAgentSessionLaunchSelection } from './structured-agent-session-launch-options'
|
||||
|
||||
type StructuredLaunchStateResult = {
|
||||
@@ -125,7 +132,7 @@ function adoptPairedHostSeed(
|
||||
}
|
||||
|
||||
function resetStructuredLaunchCallers(state: StructuredLaunchState): void {
|
||||
state.callers = createStructuredLaunchCallerGroup()
|
||||
state.callers = createStructuredLaunchCallerGroup({ kind: 'retry' })
|
||||
state.callers.onSettled = () => maybeCleanupLaunchState(state)
|
||||
}
|
||||
|
||||
@@ -149,39 +156,65 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void {
|
||||
notifyStructuredLaunchListeners()
|
||||
}
|
||||
|
||||
function joinStructuredLaunchState(
|
||||
existing: StructuredLaunchState,
|
||||
agent: AgentSessionHandleProvider,
|
||||
options: StructuredAgentLaunchOptions,
|
||||
request: StructuredLaunchRequest
|
||||
): StructuredLaunchStateResult | undefined {
|
||||
// A repeat (a double click) shares the text the first click staged, so it is sent once.
|
||||
const repeat = repeatedStructuredLaunchAttempt(existing.callers.attempt, request)
|
||||
// An empty chat takes the first text sent to it, delivered the way that request asked.
|
||||
const claim = claimableStructuredLaunchAttempt(existing, request)
|
||||
const retrying = existing.visibilityUnknown
|
||||
const joined = joinLaunchDelivery(
|
||||
options,
|
||||
claim ? options.promptDelivery : existing.promptDelivery
|
||||
)
|
||||
// Why: an unconfirmed launch keeps its draft/outbox, so a recheck must not stage it twice.
|
||||
const text = retrying || repeat ? '' : outboxPromptText(joined)
|
||||
const stagedPrompt = text
|
||||
? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text)
|
||||
: (repeat?.stagedEntry ?? null)
|
||||
// An unstaged claim stays unclaimed: the new launch it falls to reports the failure.
|
||||
if (claim && text && !stagedPrompt) {
|
||||
return undefined
|
||||
}
|
||||
if (retrying) {
|
||||
restartStructuredLaunchState(existing)
|
||||
}
|
||||
if (claim) {
|
||||
existing.promptDelivery = options.promptDelivery
|
||||
Object.assign(claim, { request, stagedEntry: stagedPrompt })
|
||||
}
|
||||
if (!retrying && !repeat) {
|
||||
launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined)
|
||||
}
|
||||
const { prompt: _retryPrompt, ...joinedWithoutPrompt } = joined
|
||||
const callerOptions = retrying ? joinedWithoutPrompt : joined
|
||||
return {
|
||||
state: existing,
|
||||
caller: addStructuredLaunchCaller({
|
||||
group: existing.callers,
|
||||
launchResult: existing.promise,
|
||||
target: existing.intent.target,
|
||||
options: callerOptions,
|
||||
stagedEntry: stagedPrompt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function structuredAgentLaunchState(
|
||||
worktreeId: string,
|
||||
agent: AgentSessionHandleProvider,
|
||||
options: StructuredAgentLaunchOptions
|
||||
): StructuredLaunchStateResult {
|
||||
const identity = structuredLaunchIdentity(worktreeId, agent, options.resumeFrom)
|
||||
const existing = getStructuredLaunchState(identity)
|
||||
if (existing) {
|
||||
const retrying = existing.visibilityUnknown || existing.callers.outcome === 'failed'
|
||||
if (retrying) {
|
||||
restartStructuredLaunchState(existing)
|
||||
}
|
||||
const joined = joinLaunchDelivery(options, existing.promptDelivery)
|
||||
// Why: failed launches keep their draft/outbox, so a retry must not stage the same prompt twice.
|
||||
const text = retrying ? '' : outboxPromptText(joined)
|
||||
const stagedPrompt = text
|
||||
? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text)
|
||||
: null
|
||||
if (!retrying) {
|
||||
launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined)
|
||||
}
|
||||
const { prompt: _retryPrompt, ...joinedWithoutPrompt } = joined
|
||||
const callerOptions = retrying ? joinedWithoutPrompt : joined
|
||||
return {
|
||||
state: existing,
|
||||
caller: addStructuredLaunchCaller({
|
||||
group: existing.callers,
|
||||
launchResult: existing.promise,
|
||||
target: existing.intent.target,
|
||||
options: callerOptions,
|
||||
stagedEntry: stagedPrompt
|
||||
})
|
||||
}
|
||||
const request = structuredLaunchRequest(options)
|
||||
const existing = getJoinableStructuredLaunchState(identity, request)
|
||||
const joined = existing && joinStructuredLaunchState(existing, agent, options, request)
|
||||
if (joined) {
|
||||
return joined
|
||||
}
|
||||
|
||||
const intent = createStructuredAgentSessionLaunchIntent(
|
||||
@@ -196,7 +229,11 @@ function structuredAgentLaunchState(
|
||||
? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text)
|
||||
: null
|
||||
launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options)
|
||||
const callers = createStructuredLaunchCallerGroup()
|
||||
const callers = createStructuredLaunchCallerGroup({
|
||||
kind: 'first',
|
||||
request,
|
||||
stagedEntry: stagedPrompt
|
||||
})
|
||||
const state: StructuredLaunchState = {
|
||||
identity,
|
||||
intent,
|
||||
|
||||
@@ -29,7 +29,7 @@ import { beginStructuredAgentSessionProvisionalLaunch } from './structured-agent
|
||||
import { beginDirectWorkItemStructuredLaunch } from './launch-work-item-direct-agent-routing'
|
||||
import type { AiVaultSession } from '../../../shared/ai-vault-types'
|
||||
import { resumeAiVaultSessionInNewChat } from '@/components/right-sidebar/ai-vault-session-resume-in-chat-launch'
|
||||
import { getStructuredAgentLaunchStatus } from './structured-agent-session-launch-registry'
|
||||
import { getStructuredAgentLaunchStatus } from './structured-agent-session-launch-status'
|
||||
import { getStructuredAgentSessionLaunchSelection } from './structured-agent-session-launch-options'
|
||||
import { peekWebSessionFocusIntent } from '@/runtime/web-session-focus-intent'
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@ describe('createUISlice agent send target mode', () => {
|
||||
it('sends to the live leaf PTY, runs delivery callback, tracks followup, and closes', async () => {
|
||||
const store = createAgentSendStore()
|
||||
const onPromptDelivered = vi.fn()
|
||||
const onPromptHandedOff = vi.fn()
|
||||
seedAgentSendState(store)
|
||||
store.getState().openAgentSendPopoverTargetMode({
|
||||
id: 'send-1',
|
||||
@@ -290,11 +291,16 @@ describe('createUISlice agent send target mode', () => {
|
||||
prompt: 'Review this',
|
||||
label: 'All unsent notes',
|
||||
launchSource: 'notes_send',
|
||||
onPromptDelivered
|
||||
onPromptDelivered,
|
||||
onPromptHandedOff
|
||||
})
|
||||
|
||||
await expect(store.getState().sendPromptToSidebarAgentTarget(readyPaneKey)).resolves.toBe(true)
|
||||
|
||||
// The notes leave the next send for exactly this send's lifetime.
|
||||
expect(onPromptHandedOff).toHaveBeenCalledOnce()
|
||||
await expect(onPromptHandedOff.mock.calls[0][0]).resolves.toMatchObject({ status: 'sent' })
|
||||
|
||||
expect(mocks.sendNotesToActiveAgentSession).toHaveBeenCalledWith({
|
||||
worktreeId,
|
||||
prompt: 'Review this',
|
||||
@@ -400,6 +406,26 @@ describe('createUISlice agent send target mode', () => {
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('Sent to Claude')
|
||||
})
|
||||
|
||||
it('sends nothing to a sidebar agent when every note is already on its way', async () => {
|
||||
const store = createAgentSendStore()
|
||||
const onPromptHandedOff = vi.fn()
|
||||
seedAgentSendState(store)
|
||||
store.getState().openAgentSendPopoverTargetMode({
|
||||
id: 'send-1',
|
||||
worktreeId,
|
||||
source: 'diff-notes',
|
||||
prompt: '',
|
||||
label: 'All unsent notes',
|
||||
launchSource: 'notes_send',
|
||||
onPromptHandedOff
|
||||
})
|
||||
|
||||
await expect(store.getState().sendPromptToSidebarAgentTarget(readyPaneKey)).resolves.toBe(false)
|
||||
|
||||
expect(mocks.sendNotesToActiveAgentSession).not.toHaveBeenCalled()
|
||||
expect(onPromptHandedOff).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps target mode open and does not run delivery callback when send fails', async () => {
|
||||
const store = createAgentSendStore()
|
||||
const onPromptDelivered = vi.fn()
|
||||
|
||||
@@ -120,7 +120,8 @@ export function createUiAgentActions(
|
||||
}),
|
||||
sendPromptToSidebarAgentTarget: async (paneKey) => {
|
||||
const mode = get().agentSendPopoverTargetMode
|
||||
if (!mode || mode.status === 'sending') {
|
||||
// An empty prompt has nothing to send: every note may already be on its way.
|
||||
if (!mode || mode.status === 'sending' || !mode.prompt.trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -154,7 +155,7 @@ export function createUiAgentActions(
|
||||
import('@/lib/active-agent-note-send'),
|
||||
import('@/lib/agent-message-send')
|
||||
])
|
||||
const result = await sendMessageToAgent({
|
||||
const sending = sendMessageToAgent({
|
||||
worktreeId: mode.worktreeId,
|
||||
prompt: mode.prompt,
|
||||
target: runningAgentMessageTarget(target)
|
||||
@@ -164,6 +165,8 @@ export function createUiAgentActions(
|
||||
})
|
||||
return { status: 'status-unavailable' as const, code: 'runtime-unverifiable' as const }
|
||||
})
|
||||
mode.onPromptHandedOff?.(sending)
|
||||
const result = await sending
|
||||
|
||||
const stillCurrent = (): boolean => {
|
||||
const current = get().agentSendPopoverTargetMode
|
||||
|
||||
@@ -39,6 +39,8 @@ export type AgentSendPopoverTargetMode = {
|
||||
sendingPaneKey?: string
|
||||
error?: string
|
||||
onPromptDelivered?: () => void
|
||||
/** Told the send's own result the moment the prompt is handed to an agent. */
|
||||
onPromptHandedOff?: (delivered: Promise<unknown>) => void
|
||||
}
|
||||
|
||||
export type OpenAgentSendPopoverTargetModeArgs = {
|
||||
@@ -49,6 +51,7 @@ export type OpenAgentSendPopoverTargetModeArgs = {
|
||||
label: string
|
||||
launchSource: LaunchSource
|
||||
onPromptDelivered?: () => void
|
||||
onPromptHandedOff?: (delivered: Promise<unknown>) => void
|
||||
}
|
||||
|
||||
export type TaskPageData = {
|
||||
|
||||
Reference in New Issue
Block a user