mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
fix(notes): a new chat's saved message keeps its notes out of another send across a reload
The hold that keeps notes sent to a new agent out of the next send lived only in memory. A reload while that chat had not yet sent them put the notes back on the shelf while the chat's saved message still carried their text, so a second "Send notes" sent them twice. The staged message now saves the send keys of the notes it was built from. The shelf treats a note as on its way while any saved message carries its key, read from the saved outboxes on first use and kept current by the outbox's one write funnel. When a message carrying notes is sent on (its own start, a Retry or the re-check), those notes are cleared from their shelf; when it is thrown away with its chat, they come back. Browser annotations sent to a new chat use the same keys while the app runs. Running-agent sends keep their in-memory hold. This replaces the per-message watch and outcome promise from the previous change.
This commit is contained in:
+4
-3
@@ -1,12 +1,13 @@
|
||||
import React from 'react'
|
||||
import { ReviewNotesSendMenuContent } from '@/components/editor/ReviewNotesSendMenuContent'
|
||||
import type { NotesSendHandOff } from '@/lib/notes-send-in-flight'
|
||||
|
||||
export type BrowserAnnotationSendMenuContentProps = {
|
||||
worktreeId: string
|
||||
groupId: string
|
||||
prompt: string
|
||||
onPromptDelivered?: () => void
|
||||
onPromptHandedOff?: (delivered: Promise<unknown>) => void
|
||||
notesHandOff?: NotesSendHandOff
|
||||
}
|
||||
|
||||
export function BrowserAnnotationSendMenuContent({
|
||||
@@ -14,7 +15,7 @@ export function BrowserAnnotationSendMenuContent({
|
||||
groupId,
|
||||
prompt,
|
||||
onPromptDelivered,
|
||||
onPromptHandedOff
|
||||
notesHandOff
|
||||
}: BrowserAnnotationSendMenuContentProps): React.JSX.Element {
|
||||
return (
|
||||
<ReviewNotesSendMenuContent
|
||||
@@ -26,7 +27,7 @@ export function BrowserAnnotationSendMenuContent({
|
||||
promptDelivery="submit-after-ready"
|
||||
launchSource="notes_send"
|
||||
onPromptDelivered={onPromptDelivered}
|
||||
onPromptHandedOff={onPromptHandedOff}
|
||||
notesHandOff={notesHandOff}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -75,7 +75,7 @@ export function BrowserGuestAnnotateOverlays({
|
||||
activeGroupId,
|
||||
browserAnnotationsPrompt,
|
||||
handleBrowserAnnotationsSentToAgent,
|
||||
handleBrowserAnnotationsHandedOff,
|
||||
browserAnnotationsHandOff,
|
||||
handleCopyBrowserAnnotations,
|
||||
browserAnnotationsCopied,
|
||||
handleClearBrowserAnnotations,
|
||||
@@ -120,7 +120,7 @@ export function BrowserGuestAnnotateOverlays({
|
||||
activeGroupId={activeGroupId}
|
||||
browserAnnotationsPrompt={browserAnnotationsPrompt}
|
||||
handleBrowserAnnotationsSentToAgent={handleBrowserAnnotationsSentToAgent}
|
||||
handleBrowserAnnotationsHandedOff={handleBrowserAnnotationsHandedOff}
|
||||
browserAnnotationsHandOff={browserAnnotationsHandOff}
|
||||
handleCopyBrowserAnnotations={handleCopyBrowserAnnotations}
|
||||
browserAnnotationsCopied={browserAnnotationsCopied}
|
||||
handleClearBrowserAnnotations={handleClearBrowserAnnotations}
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ function renderTray(currentUrl?: string): {
|
||||
activeGroupId={undefined}
|
||||
browserAnnotationsPrompt="prompt"
|
||||
handleBrowserAnnotationsSentToAgent={vi.fn()}
|
||||
handleBrowserAnnotationsHandedOff={vi.fn()}
|
||||
browserAnnotationsHandOff={{ carriedNoteKeys: [], handOff: vi.fn() }}
|
||||
handleCopyBrowserAnnotations={vi.fn()}
|
||||
browserAnnotationsCopied={false}
|
||||
handleClearBrowserAnnotations={vi.fn()}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { BROWSER_ANNOTATION_INTENT_OPTIONS } from '../describe-page/browser-anno
|
||||
import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent'
|
||||
import { preventAgentSendTargetOutsideDismiss } from './prevent-agent-send-target-outside-dismiss'
|
||||
import { browserAnnotationMatchesPageUrl } from './browser-annotation-page-url'
|
||||
import type { NotesSendHandOff } from '@/lib/notes-send-in-flight'
|
||||
|
||||
export function BrowserPageAnnotationTray({
|
||||
browserAnnotations,
|
||||
@@ -29,7 +30,7 @@ export function BrowserPageAnnotationTray({
|
||||
activeGroupId,
|
||||
browserAnnotationsPrompt,
|
||||
handleBrowserAnnotationsSentToAgent,
|
||||
handleBrowserAnnotationsHandedOff,
|
||||
browserAnnotationsHandOff,
|
||||
handleCopyBrowserAnnotations,
|
||||
browserAnnotationsCopied,
|
||||
handleClearBrowserAnnotations,
|
||||
@@ -44,7 +45,7 @@ export function BrowserPageAnnotationTray({
|
||||
activeGroupId: string | undefined
|
||||
browserAnnotationsPrompt: string
|
||||
handleBrowserAnnotationsSentToAgent: () => void
|
||||
handleBrowserAnnotationsHandedOff: (delivered: Promise<unknown>) => void
|
||||
browserAnnotationsHandOff: NotesSendHandOff
|
||||
handleCopyBrowserAnnotations: () => void
|
||||
browserAnnotationsCopied: boolean
|
||||
handleClearBrowserAnnotations: () => void
|
||||
@@ -142,7 +143,7 @@ export function BrowserPageAnnotationTray({
|
||||
groupId={activeGroupId ?? worktreeId}
|
||||
prompt={browserAnnotationsPrompt}
|
||||
onPromptDelivered={handleBrowserAnnotationsSentToAgent}
|
||||
onPromptHandedOff={handleBrowserAnnotationsHandedOff}
|
||||
notesHandOff={browserAnnotationsHandOff}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
+3
-3
@@ -228,7 +228,7 @@ describe('website annotations handed to a send', () => {
|
||||
const firstDelivered = view.result.current.handleBrowserAnnotationsSentToAgent
|
||||
let deliverFirst!: (result: { delivered: boolean }) => void
|
||||
act(() =>
|
||||
view.result.current.handleBrowserAnnotationsHandedOff(
|
||||
view.result.current.browserAnnotationsHandOff.handOff(
|
||||
new Promise((resolve) => (deliverFirst = resolve))
|
||||
)
|
||||
)
|
||||
@@ -249,7 +249,7 @@ describe('website annotations handed to a send', () => {
|
||||
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))
|
||||
act(() => view.result.current.browserAnnotationsHandOff.handOff(delivered))
|
||||
expect(view.result.current.browserAnnotationsPrompt).toBe('')
|
||||
|
||||
await act(async () => {
|
||||
@@ -269,7 +269,7 @@ describe('website annotations handed to a send', () => {
|
||||
|
||||
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.browserAnnotationsHandOff.handOff(new Promise(() => undefined)))
|
||||
|
||||
act(() => view.result.current.handleAnnotationTraySendOpenChange(true))
|
||||
expect(mode).toBeUndefined()
|
||||
|
||||
+13
-10
@@ -17,8 +17,10 @@ import type {
|
||||
import { formatBrowserAnnotationsAsMarkdown } from './browser-annotation-output'
|
||||
import { EMPTY_BROWSER_ANNOTATIONS } from '../describe-page/browser-annotation-geometry'
|
||||
import {
|
||||
holdNotesForSend,
|
||||
browserAnnotationSendKey,
|
||||
isNoteInFlight,
|
||||
notesSendHandOff,
|
||||
type NotesSendHandOff,
|
||||
useNotesInFlightVersion
|
||||
} from '@/lib/notes-send-in-flight'
|
||||
|
||||
@@ -47,7 +49,7 @@ export function useBrowserPageAnnotationSend({
|
||||
intent: BrowserAnnotationIntent
|
||||
) => void
|
||||
handleBrowserAnnotationsSentToAgent: () => void
|
||||
handleBrowserAnnotationsHandedOff: (delivered: Promise<unknown>) => void
|
||||
browserAnnotationsHandOff: NotesSendHandOff
|
||||
activeGroupId: string | undefined
|
||||
} {
|
||||
const browserAnnotations = useAppStore(
|
||||
@@ -66,7 +68,9 @@ export function useBrowserPageAnnotationSend({
|
||||
const inFlightVersion = useNotesInFlightVersion()
|
||||
const sendableAnnotations = useMemo(() => {
|
||||
void inFlightVersion
|
||||
return browserAnnotations.filter((annotation) => !isNoteInFlight(annotation))
|
||||
return browserAnnotations.filter(
|
||||
(annotation) => !isNoteInFlight(browserAnnotationSendKey(annotation))
|
||||
)
|
||||
}, [browserAnnotations, inFlightVersion])
|
||||
const browserAnnotationsPrompt = useMemo(
|
||||
() => formatBrowserAnnotationsAsMarkdown(sendableAnnotations),
|
||||
@@ -118,10 +122,9 @@ export function useBrowserPageAnnotationSend({
|
||||
removeDeliveredBrowserPageAnnotations
|
||||
])
|
||||
|
||||
const handleBrowserAnnotationsHandedOff = useCallback(
|
||||
(delivered: Promise<unknown>): void =>
|
||||
holdNotesForSend(sendableAnnotations, delivered, handleBrowserAnnotationsSentToAgent),
|
||||
[handleBrowserAnnotationsSentToAgent, sendableAnnotations]
|
||||
const browserAnnotationsHandOff = useMemo(
|
||||
() => notesSendHandOff(sendableAnnotations.map(browserAnnotationSendKey)),
|
||||
[sendableAnnotations]
|
||||
)
|
||||
|
||||
const handleClearBrowserAnnotations = useCallback((): void => {
|
||||
@@ -152,7 +155,7 @@ export function useBrowserPageAnnotationSend({
|
||||
),
|
||||
launchSource: 'notes_send',
|
||||
onPromptDelivered: handleBrowserAnnotationsSentToAgent,
|
||||
onPromptHandedOff: handleBrowserAnnotationsHandedOff
|
||||
onPromptHandedOff: browserAnnotationsHandOff.handOff
|
||||
})
|
||||
} else {
|
||||
closeAgentSendPopoverTargetMode(modeId)
|
||||
@@ -161,7 +164,7 @@ export function useBrowserPageAnnotationSend({
|
||||
[
|
||||
browserAnnotationsPrompt,
|
||||
handleBrowserAnnotationsSentToAgent,
|
||||
handleBrowserAnnotationsHandedOff,
|
||||
browserAnnotationsHandOff,
|
||||
closeAgentSendPopoverTargetMode,
|
||||
openAgentSendPopoverTargetMode,
|
||||
worktreeId
|
||||
@@ -227,7 +230,7 @@ export function useBrowserPageAnnotationSend({
|
||||
handleDeleteBrowserAnnotation,
|
||||
handleUpdateBrowserAnnotation,
|
||||
handleBrowserAnnotationsSentToAgent,
|
||||
handleBrowserAnnotationsHandedOff,
|
||||
browserAnnotationsHandOff,
|
||||
activeGroupId
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -14,6 +14,7 @@ import type { BrowserGrabPayload } from '../../../../../shared/browser-grab-type
|
||||
import type { GrabModeHook } from '../annotate/useGrabMode'
|
||||
import { preventAgentSendTargetOutsideDismiss } from '../annotate/prevent-agent-send-target-outside-dismiss'
|
||||
import type { GrabIntent } from '../describe-page/browser-page-types'
|
||||
import type { NotesSendHandOff } from '@/lib/notes-send-in-flight'
|
||||
|
||||
export function BrowserPageChromeBanners({
|
||||
resourceNotice,
|
||||
@@ -28,7 +29,7 @@ export function BrowserPageChromeBanners({
|
||||
activeGroupId,
|
||||
browserAnnotationsPrompt,
|
||||
handleBrowserAnnotationsSentToAgent,
|
||||
handleBrowserAnnotationsHandedOff,
|
||||
browserAnnotationsHandOff,
|
||||
handleCopyBrowserAnnotations,
|
||||
browserAnnotationsCopied,
|
||||
handleClearBrowserAnnotations,
|
||||
@@ -46,7 +47,7 @@ export function BrowserPageChromeBanners({
|
||||
activeGroupId: string | undefined
|
||||
browserAnnotationsPrompt: string
|
||||
handleBrowserAnnotationsSentToAgent: () => void
|
||||
handleBrowserAnnotationsHandedOff: (delivered: Promise<unknown>) => void
|
||||
browserAnnotationsHandOff: NotesSendHandOff
|
||||
handleCopyBrowserAnnotations: () => void
|
||||
browserAnnotationsCopied: boolean
|
||||
handleClearBrowserAnnotations: () => void
|
||||
@@ -162,7 +163,7 @@ export function BrowserPageChromeBanners({
|
||||
groupId={activeGroupId ?? worktreeId}
|
||||
prompt={browserAnnotationsPrompt}
|
||||
onPromptDelivered={handleBrowserAnnotationsSentToAgent}
|
||||
onPromptHandedOff={handleBrowserAnnotationsHandedOff}
|
||||
notesHandOff={browserAnnotationsHandOff}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ export function BrowserPageChromeHeader({
|
||||
activeGroupId={annotationSend.activeGroupId}
|
||||
browserAnnotationsPrompt={annotationSend.browserAnnotationsPrompt}
|
||||
handleBrowserAnnotationsSentToAgent={annotationSend.handleBrowserAnnotationsSentToAgent}
|
||||
handleBrowserAnnotationsHandedOff={annotationSend.handleBrowserAnnotationsHandedOff}
|
||||
browserAnnotationsHandOff={annotationSend.browserAnnotationsHandOff}
|
||||
handleCopyBrowserAnnotations={annotationSend.handleCopyBrowserAnnotations}
|
||||
browserAnnotationsCopied={annotationSend.browserAnnotationsCopied}
|
||||
handleClearBrowserAnnotations={annotationSend.handleClearBrowserAnnotations}
|
||||
|
||||
@@ -9,10 +9,10 @@ type ReactElementLike = {
|
||||
props: Record<string, unknown>
|
||||
}
|
||||
|
||||
type TestNote = DiffCommentDeliverySnapshot
|
||||
type TestNote = DiffCommentDeliverySnapshot & { worktreeId: string }
|
||||
|
||||
function note(id: string): TestNote {
|
||||
return { id, body: `body of ${id}`, filePath: 'README.md', lineNumber: 1 }
|
||||
return { id, worktreeId: 'wt-1', body: `body of ${id}`, filePath: 'README.md', lineNumber: 1 }
|
||||
}
|
||||
|
||||
const hookRuntime = vi.hoisted(() => ({
|
||||
@@ -475,13 +475,20 @@ describe('NotesSendMenu notes in flight', () => {
|
||||
}
|
||||
return callback(...args)
|
||||
}
|
||||
const handOffOf = (props: Record<string, unknown>): Record<string, unknown> => {
|
||||
const handOff = props.notesHandOff
|
||||
if (typeof handOff !== 'object' || handOff === null) {
|
||||
throw new Error('notesHandOff is missing')
|
||||
}
|
||||
return { ...handOff }
|
||||
}
|
||||
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)
|
||||
invoke(handOffOf(props), 'handOff', delivered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,16 +567,4 @@ describe('NotesSendMenu notes in flight', () => {
|
||||
|
||||
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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ import { ReviewNotesSendMenuContent } from './ReviewNotesSendMenuContent'
|
||||
import type { DiffCommentDeliverySnapshot } from '@/store/slices/diffComments'
|
||||
import {
|
||||
diffCommentSendKey,
|
||||
holdNotesForSend,
|
||||
notesSendHandOff,
|
||||
isNoteInFlight,
|
||||
useNotesInFlightVersion
|
||||
} from '@/lib/notes-send-in-flight'
|
||||
@@ -69,7 +69,7 @@ export function buildNotesSendTargetModeId(modeIdParts: readonly string[]): stri
|
||||
return `note-send:${modeIdParts.map((part) => `${part.length}:${part}`).join('|')}`
|
||||
}
|
||||
|
||||
export function NotesSendMenu<TNote extends DiffCommentDeliverySnapshot>({
|
||||
export function NotesSendMenu<TNote extends DiffCommentDeliverySnapshot & { worktreeId: string }>({
|
||||
worktreeId,
|
||||
groupId,
|
||||
modeIdParts,
|
||||
@@ -122,10 +122,9 @@ export function NotesSendMenu<TNote extends DiffCommentDeliverySnapshot>({
|
||||
},
|
||||
[onDelivered]
|
||||
)
|
||||
const holdInFlight = useCallback(
|
||||
(notes: readonly TNote[]) => (delivered: Promise<unknown>) =>
|
||||
holdNotesForSend(notes.map(diffCommentSendKey), delivered, () => markDelivered(notes)),
|
||||
[markDelivered]
|
||||
const handOffNotes = useCallback(
|
||||
(notes: readonly TNote[]) => notesSendHandOff(notes.map(diffCommentSendKey)),
|
||||
[]
|
||||
)
|
||||
|
||||
const openTargetMode = useCallback(
|
||||
@@ -141,11 +140,11 @@ export function NotesSendMenu<TNote extends DiffCommentDeliverySnapshot>({
|
||||
label: targetModeLabel ?? scope.label,
|
||||
launchSource: 'notes_send',
|
||||
onPromptDelivered: () => markDelivered(scope.notes),
|
||||
onPromptHandedOff: holdInFlight(scope.notes)
|
||||
onPromptHandedOff: handOffNotes(scope.notes).handOff
|
||||
})
|
||||
},
|
||||
[
|
||||
holdInFlight,
|
||||
handOffNotes,
|
||||
markDelivered,
|
||||
openAgentSendPopoverTargetMode,
|
||||
source,
|
||||
@@ -278,7 +277,7 @@ export function NotesSendMenu<TNote extends DiffCommentDeliverySnapshot>({
|
||||
promptDelivery="submit-after-ready"
|
||||
launchSource="notes_send"
|
||||
onPromptDelivered={() => markDelivered(scope.notes)}
|
||||
onPromptHandedOff={holdInFlight(scope.notes)}
|
||||
notesHandOff={handOffNotes(scope.notes)}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
@@ -296,7 +295,7 @@ export function NotesSendMenu<TNote extends DiffCommentDeliverySnapshot>({
|
||||
markDelivered(defaultScope.notes)
|
||||
}
|
||||
}}
|
||||
onPromptHandedOff={holdInFlight(defaultScope?.notes ?? [])}
|
||||
notesHandOff={handOffNotes(defaultScope?.notes ?? [])}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -650,7 +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()
|
||||
const notesHandOff = { carriedNoteKeys: ['note-a'], handOff: vi.fn() }
|
||||
setStore({
|
||||
tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] },
|
||||
terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') }
|
||||
@@ -666,7 +666,7 @@ describe('ReviewNotesSendMenuContent', () => {
|
||||
}
|
||||
]
|
||||
|
||||
const tree = render({ onPromptDelivered, onPromptHandedOff })
|
||||
const tree = render({ onPromptDelivered, notesHandOff })
|
||||
;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)()
|
||||
await flushMicrotasks()
|
||||
|
||||
@@ -682,8 +682,8 @@ describe('ReviewNotesSendMenuContent', () => {
|
||||
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(notesHandOff.handOff).toHaveBeenCalledOnce()
|
||||
await notesHandOff.handOff.mock.calls[0][0]
|
||||
expect(onPromptDelivered).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -859,15 +859,15 @@ describe('ReviewNotesSendMenuContent', () => {
|
||||
})
|
||||
|
||||
it('always offers the new-agent launcher', () => {
|
||||
const onPromptHandedOff = vi.fn()
|
||||
const tree = render({ onPromptHandedOff })
|
||||
const notesHandOff = { carriedNoteKeys: ['note-a'], handOff: vi.fn() }
|
||||
const tree = render({ notesHandOff })
|
||||
|
||||
expect(findByType(tree, 'QuickLaunchAgentMenuItems').props).toMatchObject({
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1',
|
||||
prompt: 'my notes',
|
||||
launchSource: 'notes_send',
|
||||
onPromptHandedOff
|
||||
notesHandOff
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useWorktreeAgentRows } from '@/components/sidebar/useWorktreeAgentRows'
|
||||
import type { LaunchSource } from '../../../../shared/telemetry-events'
|
||||
import { agentRowDisplayDotState } from '@/lib/agent-row-dot-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { NotesSendHandOff } from '@/lib/notes-send-in-flight'
|
||||
|
||||
type OrderedSendTarget = {
|
||||
target: NotesSendAgentTarget
|
||||
@@ -47,7 +48,7 @@ export function ReviewNotesSendMenuContent({
|
||||
promptDelivery = 'submit-after-ready',
|
||||
launchSource = 'notes_send',
|
||||
onPromptDelivered,
|
||||
onPromptHandedOff
|
||||
notesHandOff
|
||||
}: {
|
||||
worktreeId: string
|
||||
groupId: string
|
||||
@@ -55,8 +56,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
|
||||
/** The notes this prompt was built from: held from each send's hand-off until its result. */
|
||||
notesHandOff?: NotesSendHandOff
|
||||
}): React.JSX.Element {
|
||||
const hasPrompt = prompt.trim().length > 0
|
||||
|
||||
@@ -150,9 +151,9 @@ export function ReviewNotesSendMenuContent({
|
||||
{ id: pending }
|
||||
)
|
||||
})
|
||||
onPromptHandedOff?.(sending)
|
||||
notesHandOff?.handOff(sending)
|
||||
},
|
||||
[onPromptHandedOff]
|
||||
[notesHandOff]
|
||||
)
|
||||
|
||||
const sendToAgentTarget = useCallback(
|
||||
@@ -212,7 +213,7 @@ export function ReviewNotesSendMenuContent({
|
||||
promptDelivery={promptDelivery}
|
||||
launchSource={launchSource}
|
||||
onPromptDelivered={onPromptDelivered}
|
||||
onPromptHandedOff={onPromptHandedOff}
|
||||
notesHandOff={notesHandOff}
|
||||
disabled={!hasPrompt}
|
||||
/>
|
||||
</>
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
|
||||
/** How a 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 StructuredAgentSessionOutboxRemoval = 'spent' | 'discarded'
|
||||
|
||||
// Why: the notes a chat's queued messages carry, read from the saved outboxes once and kept current
|
||||
// by the outbox's one write funnel. A message's notes go when it does, so nothing here outlives it.
|
||||
const carriedBySession = new Map<string, readonly string[]>()
|
||||
let loaded = false
|
||||
let version = 0
|
||||
const changeListeners = new Set<() => void>()
|
||||
const spentListeners = new Set<(keys: readonly string[]) => void>()
|
||||
|
||||
function carriedKeys(entries: readonly StructuredAgentSessionOutboxEntry[]): string[] {
|
||||
return entries.flatMap((entry) => entry.carriedNoteKeys ?? [])
|
||||
}
|
||||
|
||||
function setCarried(
|
||||
sessionId: string,
|
||||
entries: readonly StructuredAgentSessionOutboxEntry[]
|
||||
): boolean {
|
||||
const next = carriedKeys(entries)
|
||||
const previous = carriedBySession.get(sessionId) ?? []
|
||||
if (next.length === 0) {
|
||||
carriedBySession.delete(sessionId)
|
||||
} else {
|
||||
carriedBySession.set(sessionId, next)
|
||||
}
|
||||
return next.length !== previous.length || next.some((key, index) => key !== previous[index])
|
||||
}
|
||||
|
||||
function changed(): void {
|
||||
version += 1
|
||||
for (const listener of changeListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads every saved outbox once, the first time the notes they carry are asked about. */
|
||||
export function loadStructuredAgentSessionCarriedNotes(
|
||||
saved: () => Iterable<readonly [string, readonly StructuredAgentSessionOutboxEntry[]]>
|
||||
): void {
|
||||
if (loaded) {
|
||||
return
|
||||
}
|
||||
loaded = true
|
||||
for (const [sessionId, entries] of saved()) {
|
||||
setCarried(sessionId, entries)
|
||||
}
|
||||
}
|
||||
|
||||
// A user's Retry of a refused message gives it a new id; its text and queue time stay.
|
||||
function stillQueued(
|
||||
message: StructuredAgentSessionOutboxEntry,
|
||||
entries: readonly StructuredAgentSessionOutboxEntry[]
|
||||
): boolean {
|
||||
const body = JSON.stringify(message.body)
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.clientMessageId === message.clientMessageId ||
|
||||
(entry.queuedAt === message.queuedAt && JSON.stringify(entry.body) === body)
|
||||
)
|
||||
}
|
||||
|
||||
/** Run by the outbox's write funnel once `after` replaces `before` as the session's outbox. */
|
||||
export function recordStructuredAgentSessionCarriedNotes(
|
||||
sessionId: string,
|
||||
before: readonly StructuredAgentSessionOutboxEntry[],
|
||||
after: readonly StructuredAgentSessionOutboxEntry[],
|
||||
removal: StructuredAgentSessionOutboxRemoval
|
||||
): void {
|
||||
const spent =
|
||||
removal === 'spent' ? carriedKeys(before.filter((message) => !stillQueued(message, after))) : []
|
||||
// A spent message's notes are cleared before they could show as sendable again.
|
||||
if (spent.length > 0) {
|
||||
for (const listener of spentListeners) {
|
||||
listener(spent)
|
||||
}
|
||||
}
|
||||
if (setCarried(sessionId, after)) {
|
||||
changed()
|
||||
}
|
||||
}
|
||||
|
||||
export function carriedNotesInclude(key: string): boolean {
|
||||
for (const keys of carriedBySession.values()) {
|
||||
if (keys.includes(key)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function subscribeToStructuredAgentSessionCarriedNotes(listener: () => void): () => void {
|
||||
changeListeners.add(listener)
|
||||
return () => changeListeners.delete(listener)
|
||||
}
|
||||
|
||||
export function structuredAgentSessionCarriedNotesVersion(): number {
|
||||
return version
|
||||
}
|
||||
|
||||
/** Told the notes of every message sent on, whichever send or Retry sent it. */
|
||||
export function subscribeToStructuredAgentSessionCarriedNotesSpent(
|
||||
listener: (keys: readonly string[]) => void
|
||||
): () => void {
|
||||
spentListeners.add(listener)
|
||||
return () => spentListeners.delete(listener)
|
||||
}
|
||||
|
||||
export function resetStructuredAgentSessionCarriedNotesForTests(): void {
|
||||
carriedBySession.clear()
|
||||
loaded = false
|
||||
changed()
|
||||
}
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
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,9 +7,11 @@ import {
|
||||
import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import {
|
||||
settleStructuredAgentSessionOutboxEntryWatches,
|
||||
type StructuredAgentSessionOutboxEntryRemoval
|
||||
} from './structured-agent-session-outbox-entry-watch'
|
||||
carriedNotesInclude,
|
||||
loadStructuredAgentSessionCarriedNotes,
|
||||
recordStructuredAgentSessionCarriedNotes,
|
||||
type StructuredAgentSessionOutboxRemoval
|
||||
} from './structured-agent-session-outbox-carried-notes'
|
||||
|
||||
const OUTBOX_PREFIX = 'orca:desktopStructuredAgentSessionOutbox:v1:'
|
||||
|
||||
@@ -171,8 +173,9 @@ function commitOutbox(
|
||||
sessionId: string,
|
||||
entries: StructuredAgentSessionOutboxEntry[],
|
||||
options: { onlyIfSaved?: boolean },
|
||||
removal: StructuredAgentSessionOutboxEntryRemoval
|
||||
removal: StructuredAgentSessionOutboxRemoval
|
||||
): boolean {
|
||||
const before = getStructuredAgentSessionOutbox(sessionId)
|
||||
const saved = writeOutbox(sessionId, entries)
|
||||
if (!saved && options.onlyIfSaved) {
|
||||
return false
|
||||
@@ -184,17 +187,38 @@ function commitOutbox(
|
||||
listener()
|
||||
}
|
||||
}
|
||||
settleStructuredAgentSessionOutboxEntryWatches(sessionId, entries, removal)
|
||||
recordStructuredAgentSessionCarriedNotes(sessionId, before, entries, removal)
|
||||
return saved
|
||||
}
|
||||
|
||||
function* savedOutboxes(): Generator<readonly [string, StructuredAgentSessionOutboxEntry[]]> {
|
||||
// Storage-less hosts (tests, a blocked profile) have no saved outboxes.
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return
|
||||
}
|
||||
for (let index = 0; index < localStorage.length; index += 1) {
|
||||
const key = localStorage.key(index)
|
||||
if (key?.startsWith(OUTBOX_PREFIX)) {
|
||||
const sessionId = decodeURIComponent(key.slice(OUTBOX_PREFIX.length))
|
||||
yield [sessionId, readOutbox(sessionId, { recoverDispatching: false })]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a saved message still carries this note: it stays out of another send until then. */
|
||||
export function structuredAgentSessionOutboxCarriesNote(key: string): boolean {
|
||||
loadStructuredAgentSessionCarriedNotes(savedOutboxes)
|
||||
return carriedNotesInclude(key)
|
||||
}
|
||||
|
||||
/** Queues a user message on the session's outbox: the one enqueue the composer, a launch prompt,
|
||||
* and a message sent from outside the chat all share. */
|
||||
export function appendStructuredAgentSessionOutboxMessage(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
attachments: readonly StructuredAgentSessionAttachment[] = [],
|
||||
source?: 'launch'
|
||||
source?: 'launch',
|
||||
carriedNoteKeys?: readonly string[]
|
||||
): StructuredAgentSessionOutboxEntry | null {
|
||||
const entry = {
|
||||
...createStructuredAgentSessionOutboxEntry({
|
||||
@@ -204,7 +228,8 @@ export function appendStructuredAgentSessionOutboxMessage(
|
||||
attachments,
|
||||
queuedAt: Date.now()
|
||||
}),
|
||||
...(source ? { source } : {})
|
||||
...(source ? { source } : {}),
|
||||
...(carriedNoteKeys?.length ? { carriedNoteKeys: [...carriedNoteKeys] } : {})
|
||||
}
|
||||
return commitStructuredAgentSessionOutbox(
|
||||
sessionId,
|
||||
@@ -217,9 +242,10 @@ export function appendStructuredAgentSessionOutboxMessage(
|
||||
|
||||
export function enqueueStructuredAgentSessionLaunchPrompt(
|
||||
sessionId: string,
|
||||
text: string
|
||||
text: string,
|
||||
carriedNoteKeys?: readonly string[]
|
||||
): StructuredAgentSessionOutboxEntry | null {
|
||||
return appendStructuredAgentSessionOutboxMessage(sessionId, text, [], 'launch')
|
||||
return appendStructuredAgentSessionOutboxMessage(sessionId, text, [], 'launch', carriedNoteKeys)
|
||||
}
|
||||
|
||||
export function discardStructuredAgentSessionLaunchOutbox(sessionId: string): void {
|
||||
|
||||
@@ -180,14 +180,14 @@ describe('QuickLaunchAgentMenuItems launch status', () => {
|
||||
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 () => {
|
||||
// Why: a new chat saves the notes' keys with its message; the result holds them until then.
|
||||
it("saves the notes' keys with the launch and holds them on its own result", () => {
|
||||
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()
|
||||
const notesHandOff = { carriedNoteKeys: ['note-a'], handOff: vi.fn() }
|
||||
|
||||
render(
|
||||
<QuickLaunchAgentMenuItems
|
||||
@@ -196,13 +196,15 @@ describe('QuickLaunchAgentMenuItems launch status', () => {
|
||||
onFocusTerminal={vi.fn()}
|
||||
prompt="review notes"
|
||||
promptDelivery="submit-after-ready"
|
||||
onPromptHandedOff={onPromptHandedOff}
|
||||
notesHandOff={notesHandOff}
|
||||
/>
|
||||
)
|
||||
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 })
|
||||
expect(launchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prompt: 'review notes', carriedNoteKeys: ['note-a'] })
|
||||
)
|
||||
expect(notesHandOff.handOff).toHaveBeenCalledWith(delivery)
|
||||
})
|
||||
|
||||
it('starts no agent when the menu has nothing left to send', () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
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'
|
||||
import type { NotesSendHandOff } from '@/lib/notes-send-in-flight'
|
||||
|
||||
export type QuickLaunchAgentMenuItemsProps = {
|
||||
worktreeId: string
|
||||
@@ -39,8 +39,9 @@ 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
|
||||
/** The notes this prompt was built from: a new chat saves their keys with its message, and the
|
||||
* launch's own delivery result holds them until then. */
|
||||
notesHandOff?: NotesSendHandOff
|
||||
/** Nothing to send: e.g. every note is already on its way, so no agent is started. */
|
||||
disabled?: boolean
|
||||
}
|
||||
@@ -110,7 +111,7 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
promptDelivery,
|
||||
launchSource,
|
||||
onPromptDelivered,
|
||||
onPromptHandedOff,
|
||||
notesHandOff,
|
||||
disabled = false
|
||||
}: QuickLaunchAgentMenuItemsProps): React.JSX.Element | null {
|
||||
// Why: resolving only the SSH connectionId here made paired-runtime
|
||||
@@ -153,7 +154,8 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
...(prompt !== undefined ? { prompt } : {}),
|
||||
...(promptDelivery !== undefined ? { promptDelivery } : {}),
|
||||
...(launchSource !== undefined ? { launchSource } : {}),
|
||||
...(onPromptDelivered !== undefined ? { onPromptDelivered } : {})
|
||||
...(onPromptDelivered !== undefined ? { onPromptDelivered } : {}),
|
||||
...(notesHandOff ? { carriedNoteKeys: notesHandOff.carriedNoteKeys } : {})
|
||||
})
|
||||
if (!result) {
|
||||
toast.error(
|
||||
@@ -165,16 +167,8 @@ 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.promptDeliveryResult) {
|
||||
notesHandOff?.handOff(result.promptDeliveryResult)
|
||||
}
|
||||
if (result.surface.kind !== 'local-terminal') {
|
||||
return
|
||||
@@ -210,7 +204,7 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
promptDelivery,
|
||||
launchSource,
|
||||
onPromptDelivered,
|
||||
onPromptHandedOff,
|
||||
notesHandOff,
|
||||
disabled
|
||||
]
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { StructuredAgentLaunchOptions } from '@/lib/structured-agent-sessio
|
||||
export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & {
|
||||
resumeFrom?: StructuredAgentSessionResumeSource
|
||||
onPromptDelivered?: () => void
|
||||
carriedNoteKeys?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +46,8 @@ export type AgentSessionLaunchVerdict = {
|
||||
promptDelivery?: NativeChatLaunchPromptDelivery
|
||||
resumeFrom?: StructuredAgentSessionResumeSource
|
||||
onPromptDelivered?: () => void
|
||||
/** Send keys of the notes the prompt was built from; a structured launch saves them with it. */
|
||||
carriedNoteKeys?: readonly string[]
|
||||
}
|
||||
|
||||
export type AgentSessionStructuredFeasibilityRequest = AgentLaunchRouteArgs & {
|
||||
@@ -81,6 +84,7 @@ function structuredLaunchOptions(verdict: AgentSessionLaunchVerdict): Structured
|
||||
...(verdict.promptDelivery ? { promptDelivery: verdict.promptDelivery } : {}),
|
||||
...(verdict.resumeFrom ? { resumeFrom: verdict.resumeFrom } : {}),
|
||||
...(verdict.onPromptDelivered ? { onPromptDelivered: verdict.onPromptDelivered } : {}),
|
||||
...(verdict.carriedNoteKeys ? { carriedNoteKeys: verdict.carriedNoteKeys } : {}),
|
||||
...(verdict.executionHostId ? { executionHostId: verdict.executionHostId } : {})
|
||||
}
|
||||
}
|
||||
@@ -170,6 +174,7 @@ export function planAgentSessionLaunch(
|
||||
...(request.prompt !== undefined ? { prompt: request.prompt } : {}),
|
||||
...(request.promptDelivery ? { promptDelivery: request.promptDelivery } : {}),
|
||||
...(request.resumeFrom ? { resumeFrom: request.resumeFrom } : {}),
|
||||
...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {})
|
||||
...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}),
|
||||
...(request.carriedNoteKeys ? { carriedNoteKeys: request.carriedNoteKeys } : {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ export type LaunchAgentInNewTabArgs = {
|
||||
launchPlatform?: NodeJS.Platform
|
||||
/** Called after the prompt is actually delivered to the agent input path. */
|
||||
onPromptDelivered?: () => void
|
||||
/** Send keys of the notes the prompt was built from; a structured chat saves them with it. */
|
||||
carriedNoteKeys?: readonly string[]
|
||||
/**
|
||||
* Called before `onPromptDelivered` when the paste was written without ever observing the
|
||||
* agent's composer, so the launch cannot claim the prompt arrived. Fires only on the
|
||||
@@ -115,6 +117,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
|
||||
launchPlatform,
|
||||
onPromptDelivered,
|
||||
onPromptDeliveryUnconfirmed,
|
||||
carriedNoteKeys,
|
||||
agentSessionLaunchPlan,
|
||||
pendingActivationSpawn,
|
||||
beforeSurfaceOpen
|
||||
@@ -179,7 +182,8 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent
|
||||
promptDelivery: viewModePromptDelivery,
|
||||
tuiCustomization: { cwd: initialCwd },
|
||||
initialSessionOptions: startupPlan.sessionOptions,
|
||||
onPromptDelivered
|
||||
onPromptDelivered,
|
||||
...(carriedNoteKeys ? { carriedNoteKeys } : {})
|
||||
})
|
||||
if (plan?.route === 'structured-native-chat') {
|
||||
const structured = launchStructuredAgentFromNewTab({
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
// @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)
|
||||
})
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
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,271 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
|
||||
import type { DiffComment } from '../../../shared/diff-comment-types'
|
||||
import type { BrowserPageAnnotation } from '../../../shared/browser-grab-types'
|
||||
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
|
||||
|
||||
type ShelfAnnotation = Pick<BrowserPageAnnotation, 'browserPageId' | 'id' | 'comment' | 'intent'>
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const shelf: { notes: DiffComment[]; annotations: ShelfAnnotation[] } = {
|
||||
notes: [],
|
||||
annotations: []
|
||||
}
|
||||
return {
|
||||
callStructuredAgentSession: vi.fn(),
|
||||
createIntent: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
clearDeliveredDiffComments: vi.fn(),
|
||||
removeDeliveredBrowserPageAnnotations: vi.fn(),
|
||||
shelf
|
||||
}
|
||||
})
|
||||
|
||||
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: mocks.refresh
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.callStructuredAgentSession
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
unifiedTabsByWorktree: {},
|
||||
seedNativeChatLaunchDraft: vi.fn(),
|
||||
clearNativeChatLaunchDraft: vi.fn(),
|
||||
getDiffComments: () => mocks.shelf.notes,
|
||||
clearDeliveredDiffComments: mocks.clearDeliveredDiffComments,
|
||||
browserAnnotationsByPageId: { 'page-1': mocks.shelf.annotations },
|
||||
removeDeliveredBrowserPageAnnotations: mocks.removeDeliveredBrowserPageAnnotations
|
||||
}),
|
||||
subscribe: () => () => undefined
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
|
||||
vi.mock('@/lib/agent-catalog', () => ({
|
||||
getAgentLabel: () => 'Codex',
|
||||
getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }]
|
||||
}))
|
||||
|
||||
const WORKTREE_ID = 'wt-notes-new-agent'
|
||||
const NOTES = 'review notes'
|
||||
|
||||
const note: DiffComment = {
|
||||
id: 'note-a',
|
||||
worktreeId: WORKTREE_ID,
|
||||
filePath: 'README.md',
|
||||
lineNumber: 3,
|
||||
body: 'tighten this',
|
||||
createdAt: 1,
|
||||
side: 'modified'
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
/** A renderer as it starts: fresh modules over the same saved storage, the clearer installed. */
|
||||
async function renderer() {
|
||||
const launch = await import('./structured-agent-session-launch')
|
||||
const outbox = await import('@/components/native-chat/structured-agent-session-outbox-storage')
|
||||
const inFlight = await import('./notes-send-in-flight')
|
||||
const { installNotesDeliveredByChat } = await import('./notes-delivered-by-chat')
|
||||
installNotesDeliveredByChat()
|
||||
return { launch, outbox, inFlight, key: inFlight.diffCommentSendKey(note) }
|
||||
}
|
||||
|
||||
/** What "Send notes > New agent" does: the launch, carrying the notes' keys, held on its result. */
|
||||
async function sendNotesToNewAgent() {
|
||||
const app = await renderer()
|
||||
const handOff = app.inFlight.notesSendHandOff([app.key])
|
||||
const started = app.launch.startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: NOTES,
|
||||
promptDelivery: 'submit-after-ready',
|
||||
carriedNoteKeys: handOff.carriedNoteKeys
|
||||
})
|
||||
handOff.handOff(started.promptDeliveryResult!)
|
||||
return app
|
||||
}
|
||||
|
||||
/** The chat's own send accepting its staged message, as the composer's drain or a Retry does. */
|
||||
function acceptStagedMessage(app: Awaited<ReturnType<typeof renderer>>): void {
|
||||
const [message] = app.outbox.readOutbox(chat.sessionId)
|
||||
app.outbox.mutateStructuredAgentSessionLaunchPrompt(
|
||||
chat.sessionId,
|
||||
message.clientMessageId,
|
||||
() => null
|
||||
)
|
||||
}
|
||||
|
||||
describe('notes sent to a new agent', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mocks.shelf.notes = [note]
|
||||
mocks.shelf.annotations = []
|
||||
mocks.createIntent.mockReturnValue(chat)
|
||||
mocks.refresh.mockResolvedValue([published(chat.sessionId)])
|
||||
mocks.callStructuredAgentSession.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { submission: { dispatchState: 'accepted' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('save their keys with the staged message and leave the shelf once it is sent', async () => {
|
||||
mocks.launch.mockResolvedValue({ sessionId: chat.sessionId, fence: 1 })
|
||||
const app = await sendNotesToNewAgent()
|
||||
expect(app.outbox.readOutbox(chat.sessionId)[0].carriedNoteKeys).toEqual([app.key])
|
||||
expect(app.inFlight.isNoteInFlight(app.key)).toBe(true)
|
||||
|
||||
await settle()
|
||||
|
||||
expect(mocks.clearDeliveredDiffComments).toHaveBeenCalledWith(WORKTREE_ID, [note])
|
||||
expect(app.inFlight.isNoteInFlight(app.key)).toBe(false)
|
||||
})
|
||||
|
||||
it('stay held across a reload while the chat can still send them, and clear when it does', async () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
await sendNotesToNewAgent()
|
||||
|
||||
vi.resetModules()
|
||||
const reloaded = await renderer()
|
||||
|
||||
expect(reloaded.inFlight.isNoteInFlight(reloaded.key)).toBe(true)
|
||||
acceptStagedMessage(reloaded)
|
||||
expect(mocks.clearDeliveredDiffComments).toHaveBeenCalledWith(WORKTREE_ID, [note])
|
||||
expect(reloaded.inFlight.isNoteInFlight(reloaded.key)).toBe(false)
|
||||
})
|
||||
|
||||
it('come back to the shelf when the chat is closed after a reload', async () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
await sendNotesToNewAgent()
|
||||
|
||||
vi.resetModules()
|
||||
const reloaded = await renderer()
|
||||
expect(reloaded.inFlight.isNoteInFlight(reloaded.key)).toBe(true)
|
||||
// Closing a chat throws its outbox away (tab retirement, cancel, workspace teardown).
|
||||
reloaded.outbox.discardStructuredAgentSessionLaunchOutbox(chat.sessionId)
|
||||
|
||||
expect(reloaded.inFlight.isNoteInFlight(reloaded.key)).toBe(false)
|
||||
expect(mocks.clearDeliveredDiffComments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stay held by a failed chat, clear when its Retry sends them, and return when it closes', async () => {
|
||||
const { StructuredAgentSessionCreateRefusalError } =
|
||||
await import('@/lib/launch-structured-agent-session')
|
||||
mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
|
||||
const app = await sendNotesToNewAgent()
|
||||
await settle()
|
||||
expect(app.launch.getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, chat.sessionId)).toBe(
|
||||
'failed'
|
||||
)
|
||||
expect(app.inFlight.isNoteInFlight(app.key)).toBe(true)
|
||||
|
||||
mocks.launch.mockResolvedValue({ sessionId: chat.sessionId, fence: 1 })
|
||||
expect(app.launch.retryStructuredAgentSessionLaunch(WORKTREE_ID, chat.sessionId)).toBe(true)
|
||||
await settle()
|
||||
acceptStagedMessage(app)
|
||||
|
||||
expect(mocks.clearDeliveredDiffComments).toHaveBeenCalledWith(WORKTREE_ID, [note])
|
||||
expect(app.inFlight.isNoteInFlight(app.key)).toBe(false)
|
||||
})
|
||||
|
||||
it('return when a failed chat is closed', async () => {
|
||||
const { StructuredAgentSessionCreateRefusalError } =
|
||||
await import('@/lib/launch-structured-agent-session')
|
||||
mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported'))
|
||||
const app = await sendNotesToNewAgent()
|
||||
await settle()
|
||||
|
||||
app.launch.cancelStructuredAgentLaunch(WORKTREE_ID, chat.sessionId)
|
||||
|
||||
expect(app.inFlight.isNoteInFlight(app.key)).toBe(false)
|
||||
expect(mocks.clearDeliveredDiffComments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears browser annotations a new chat sends on', async () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
mocks.shelf.annotations = [
|
||||
{ id: 'annotation-1', browserPageId: 'page-1', comment: 'Fix', intent: 'fix' }
|
||||
]
|
||||
const app = await renderer()
|
||||
const key = app.inFlight.browserAnnotationSendKey(mocks.shelf.annotations[0])
|
||||
app.launch.startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
|
||||
prompt: NOTES,
|
||||
carriedNoteKeys: [key]
|
||||
})
|
||||
|
||||
acceptStagedMessage(app)
|
||||
|
||||
expect(mocks.removeDeliveredBrowserPageAnnotations).toHaveBeenCalledWith('page-1', [
|
||||
mocks.shelf.annotations[0]
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { subscribeToStructuredAgentSessionCarriedNotesSpent } from '@/components/native-chat/structured-agent-session-outbox-carried-notes'
|
||||
import {
|
||||
browserAnnotationSendKey,
|
||||
diffCommentSendKey,
|
||||
noteSendKeyOwner
|
||||
} from './notes-send-in-flight'
|
||||
|
||||
/** A new chat's message sent on, by its own start, a Retry or a re-check, clears the notes it
|
||||
* carries from their shelf as a delivered send does, including after a reload. */
|
||||
export function installNotesDeliveredByChat(): () => void {
|
||||
return subscribeToStructuredAgentSessionCarriedNotesSpent((keys) => {
|
||||
const sent = new Set(keys)
|
||||
const state = useAppStore.getState()
|
||||
const worktreeIds = new Set<string>()
|
||||
const pageIds = new Set<string>()
|
||||
for (const key of keys) {
|
||||
const owner = noteSendKeyOwner(key)
|
||||
if (owner?.kind === 'diff-comment') {
|
||||
worktreeIds.add(owner.owner)
|
||||
} else if (owner?.kind === 'browser-annotation') {
|
||||
pageIds.add(owner.owner)
|
||||
}
|
||||
}
|
||||
for (const worktreeId of worktreeIds) {
|
||||
const notes = state
|
||||
.getDiffComments(worktreeId)
|
||||
.filter((note) => sent.has(diffCommentSendKey(note)))
|
||||
if (notes.length > 0) {
|
||||
void state.clearDeliveredDiffComments(worktreeId, notes)
|
||||
}
|
||||
}
|
||||
for (const pageId of pageIds) {
|
||||
const annotations = (state.browserAnnotationsByPageId[pageId] ?? []).filter((annotation) =>
|
||||
sent.has(browserAnnotationSendKey(annotation))
|
||||
)
|
||||
if (annotations.length > 0) {
|
||||
state.removeDeliveredBrowserPageAnnotations(pageId, annotations)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,26 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { DiffCommentDeliverySnapshot } from '@/store/slices/diffComments'
|
||||
import type { BrowserPageAnnotation } from '../../../shared/browser-grab-types'
|
||||
import { structuredAgentSessionOutboxCarriesNote } from '@/components/native-chat/structured-agent-session-outbox-storage'
|
||||
import {
|
||||
structuredAgentSessionCarriedNotesVersion,
|
||||
subscribeToStructuredAgentSessionCarriedNotes
|
||||
} from '@/components/native-chat/structured-agent-session-outbox-carried-notes'
|
||||
|
||||
// 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>()
|
||||
// Why: notes handed to a send leave the next send at once and come back only if that send fails,
|
||||
// as a submitted composer clears and restores on error. A new chat's saved message carries its
|
||||
// notes' keys, so those stay out for as long as the chat can still send them, reload included;
|
||||
// other sends hold them in memory until their own result. Delivered notes are removed by their owner.
|
||||
const holds = new Map<string, number>()
|
||||
const listeners = new Set<() => void>()
|
||||
let version = 0
|
||||
|
||||
/** What a notes send gives its route: the keys a new chat saves with its message, and the hold. */
|
||||
export type NotesSendHandOff = {
|
||||
carriedNoteKeys: readonly string[]
|
||||
handOff: (delivered: Promise<unknown>) => void
|
||||
}
|
||||
|
||||
function changed(): void {
|
||||
version += 1
|
||||
for (const listener of listeners) {
|
||||
@@ -15,32 +28,11 @@ function changed(): void {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
/** Takes `keys` out of the next send until `delivered` settles, whatever its result. */
|
||||
export function holdNotesForSend(keys: readonly string[], delivered: Promise<unknown>): 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)
|
||||
}
|
||||
@@ -59,17 +51,25 @@ export function holdNotesForSend(
|
||||
void delivered.then(release, release)
|
||||
}
|
||||
|
||||
export function isNoteInFlight(key: unknown): boolean {
|
||||
return holds.has(key)
|
||||
export function notesSendHandOff(keys: readonly string[]): NotesSendHandOff {
|
||||
return { carriedNoteKeys: keys, handOff: (delivered) => holdNotesForSend(keys, delivered) }
|
||||
}
|
||||
|
||||
export function isNoteInFlight(key: string): boolean {
|
||||
return holds.has(key) || structuredAgentSessionOutboxCarriesNote(key)
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
const unsubscribeCarried = subscribeToStructuredAgentSessionCarriedNotes(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
unsubscribeCarried()
|
||||
}
|
||||
}
|
||||
|
||||
function getVersion(): number {
|
||||
return version
|
||||
return version + structuredAgentSessionCarriedNotesVersion()
|
||||
}
|
||||
|
||||
/** Changes whenever a hold starts or ends, for memos that filter by `isNoteInFlight`. */
|
||||
@@ -77,9 +77,13 @@ export function useNotesInFlightVersion(): number {
|
||||
return useSyncExternalStore(subscribe, getVersion, getVersion)
|
||||
}
|
||||
|
||||
type DiffCommentSendKeyNote = DiffCommentDeliverySnapshot & { worktreeId: string }
|
||||
|
||||
/** A note's identity for delivery: an edit makes it a new pending note, as for its removal. */
|
||||
export function diffCommentSendKey(note: DiffCommentDeliverySnapshot): string {
|
||||
export function diffCommentSendKey(note: DiffCommentSendKeyNote): string {
|
||||
return JSON.stringify([
|
||||
'diff-comment',
|
||||
note.worktreeId,
|
||||
note.id,
|
||||
note.body,
|
||||
note.filePath,
|
||||
@@ -90,6 +94,37 @@ export function diffCommentSendKey(note: DiffCommentDeliverySnapshot): string {
|
||||
])
|
||||
}
|
||||
|
||||
export function browserAnnotationSendKey(
|
||||
annotation: Pick<BrowserPageAnnotation, 'browserPageId' | 'id' | 'comment' | 'intent'>
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
'browser-annotation',
|
||||
annotation.browserPageId,
|
||||
annotation.id,
|
||||
annotation.comment,
|
||||
annotation.intent
|
||||
])
|
||||
}
|
||||
|
||||
/** Where a saved key's note lives (its workspace or browser page), or null for a key this build
|
||||
* does not read. The note itself is found by its key. */
|
||||
export function noteSendKeyOwner(
|
||||
key: string
|
||||
): { kind: 'diff-comment' | 'browser-annotation'; owner: string } | null {
|
||||
try {
|
||||
const parts: unknown = JSON.parse(key)
|
||||
if (!Array.isArray(parts)) {
|
||||
return null
|
||||
}
|
||||
const [kind, owner] = parts
|
||||
return (kind === 'diff-comment' || kind === 'browser-annotation') && typeof owner === 'string'
|
||||
? { kind, owner }
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function resetNotesInFlightForTests(): void {
|
||||
holds.clear()
|
||||
changed()
|
||||
|
||||
@@ -10,6 +10,8 @@ export type StructuredAgentLaunchOptions = {
|
||||
prompt?: string
|
||||
promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft'
|
||||
onPromptDelivered?: () => void
|
||||
/** Send keys of the notes the prompt was built from, saved with its staged message. */
|
||||
carriedNoteKeys?: readonly string[]
|
||||
/** Adopt an existing provider conversation instead of starting a fresh one. Part of the launch's
|
||||
* identity, not a preference — see `launchIdentity`. */
|
||||
resumeFrom?: StructuredAgentSessionResumeSource
|
||||
|
||||
@@ -174,7 +174,11 @@ function joinStructuredLaunchState(
|
||||
// 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)
|
||||
? enqueueStructuredAgentSessionLaunchPrompt(
|
||||
existing.intent.sessionId,
|
||||
text,
|
||||
options.carriedNoteKeys
|
||||
)
|
||||
: (repeat?.stagedEntry ?? null)
|
||||
// An unstaged claim stays unclaimed: the new launch it falls to reports the failure.
|
||||
if (claim && text && !stagedPrompt) {
|
||||
@@ -226,7 +230,7 @@ function structuredAgentLaunchState(
|
||||
)
|
||||
const text = outboxPromptText(options)
|
||||
const stagedPrompt = text
|
||||
? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text)
|
||||
? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text, options.carriedNoteKeys)
|
||||
: null
|
||||
launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options)
|
||||
const callers = createStructuredLaunchCallerGroup({
|
||||
|
||||
@@ -24,11 +24,13 @@ import { getOrCreateRendererRoot } from './lib/react-renderer-root'
|
||||
import { primeTerminalWebglAddon } from './lib/pane-manager/pane-webgl-renderer'
|
||||
import { SkillWarningPreviewLauncher } from './components/skills/SkillWarningPreviewLauncher'
|
||||
import { installBrowserClientPageRenderer } from './components/browser-pane/browser-client-page-renderer-installation'
|
||||
import { installNotesDeliveredByChat } from './lib/notes-delivered-by-chat'
|
||||
|
||||
recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV })
|
||||
installRendererCrashDiagnostics()
|
||||
installTypingLatencyDiagnostic()
|
||||
installAutomationHostDiagnostic()
|
||||
installNotesDeliveredByChat()
|
||||
|
||||
if (
|
||||
import.meta.env.DEV &&
|
||||
|
||||
@@ -35,6 +35,9 @@ export type StructuredAgentSessionOutboxEntry = {
|
||||
lastAttemptAt: number | null
|
||||
retryAfterUnknownSubmittedAt: number | null
|
||||
source?: 'launch'
|
||||
/** The notes a launch prompt was built from, as their send keys. Saved with the message so the
|
||||
* notes stay out of another send for as long as this message can still be sent, reload included. */
|
||||
carriedNoteKeys?: string[]
|
||||
/** A Stop landed after this queue send went out: only the user's Retry sends it again, never the
|
||||
* drain, the unconfirmed probe or an owner change, which would start a turn the user stopped. */
|
||||
outlivedStop?: true
|
||||
@@ -298,6 +301,10 @@ export function parseStructuredAgentSessionOutboxEntry(
|
||||
? entry.retryAfterUnknownSubmittedAt
|
||||
: null,
|
||||
...(entry.source === 'launch' ? { source: 'launch' as const } : {}),
|
||||
...(Array.isArray(entry.carriedNoteKeys) &&
|
||||
entry.carriedNoteKeys.every((key) => typeof key === 'string')
|
||||
? { carriedNoteKeys: entry.carriedNoteKeys }
|
||||
: {}),
|
||||
...parseStructuredAgentSessionOutboxQueueFields(entry),
|
||||
...(lastFailure ? { lastFailure } : {})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user