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:
Brennan Benson
2026-10-04 03:45:41 -07:00
parent 60d7e010e5
commit b88d0dc2cc
28 changed files with 642 additions and 490 deletions
@@ -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}
/>
)
}
@@ -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}
@@ -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>
@@ -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()
@@ -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
}
}
@@ -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>
@@ -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}
/>
</>
@@ -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()
}
@@ -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)
}
}
})
}
+67 -32
View File
@@ -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({
+2
View File
@@ -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 } : {})
}